aboutsummaryrefslogtreecommitdiffstats
path: root/src/emoji.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-15 09:27:45 -0700
committerAdam Soltys <[email protected]>2026-09-15 09:27:45 -0700
commitc90d0f3c600802b5b4b43959df5fd2d02f78012c (patch)
tree0bb36a804eda41415e2d3e871bc6475836f8f99e /src/emoji.js
parentfeadbb008de9b2fe2332a7f0e190955ae545f07d (diff)
downloadcoinosv3-c90d0f3c600802b5b4b43959df5fd2d02f78012c.tar.gz
coinosv3-c90d0f3c600802b5b4b43959df5fd2d02f78012c.zip
Custom emoji in chat: :shortcodes: render as pictures, packs sync with Vector.
A message from Vector says :pika_wave: and carries a NIP-30 ["emoji", code, url] tag for it; coinos showed the bare shortcode. Now the bubble shows the picture (in rooms, DMs, the feed and the public /chat page), a bubble that is only one to three of them shows them big, and a :code: reaction chip is a picture too. Sending: every :code: you type that resolves travels with its tag, so the other side renders it whether or not they hold the pack. Shortcodes come from packs you hold (NIP-51 kind 30030 sets) and from what you've seen in chat, so anyone can answer a :pika_wave: with one. The composer autocompletes on ":pi" (Tab/Enter takes the first), the reaction picker lists custom emoji first, and Vector's pack share link renders with an Add button; a pasted naddr works too. Packs live on your kind 10030 list — the one Vector reads and writes — so a pack added in either app appears in both. tools/emoji-test.js drives the real coinos room headlessly and checks all of the above. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
Diffstat (limited to 'src/emoji.js')
-rw-r--r--src/emoji.js151
1 files changed, 151 insertions, 0 deletions
diff --git a/src/emoji.js b/src/emoji.js
new file mode 100644
index 0000000..80ccdd2
--- /dev/null
+++ b/src/emoji.js
@@ -0,0 +1,151 @@
+// Custom emoji (NIP-30) as Vector speaks it in Concord rooms and DMs: a
+// message says `:pika_wave:` in its content and carries an
+// ["emoji", "pika_wave", "https://…/x.gif"] tag for every shortcode it uses,
+// so anyone can render it without holding the pack. Packs themselves are
+// NIP-51 emoji sets (kind 30030: title/image tags + one emoji tag per entry)
+// and a user's subscriptions live in their kind 10030 list as `a` tags —
+// the same list Vector reads, so a pack added here shows up there and back.
+//
+// Pure helpers only: no state, no DOM. messages.js owns the packs the user
+// holds and the shortcodes learned from what they've seen; public-chat.js
+// uses the same tokenizer so the read-only page renders the pictures too.
+
+import { decode as nip19decode, naddrEncode } from 'nostr-tools/nip19';
+
+export const EMOJI_SET_KIND = 30030;
+export const EMOJI_LIST_KIND = 10030;
+
+// Vector's shortcode alphabet, plus `~N` — its suffix for two packs that
+// disagree on a name (`:love~2:`).
+const CODE = 'A-Za-z0-9_~-';
+export const SHORTCODE_RE = new RegExp('^[' + CODE + ']+$');
+// A splitting regex: capturing, so `text.split()` keeps the tokens.
+export const EMOJI_TOKEN_RE = new RegExp('(:[' + CODE + ']+:)', 'g');
+// What the composer autocompletes on: a colon and two+ characters, with no
+// closing colon yet, right at the caret.
+export const EMOJI_PARTIAL_RE = new RegExp('(?:^|[^:' + CODE + ']):([' + CODE + ']{2,})$');
+
+// Vector's share link for a pack; the naddr inside is the pack coordinate.
+export const PACK_LINK_RE = /^https?:\/\/(?:www\.)?vectorapp\.io\/emojis\/pack\/(naddr1[a-z0-9]+)\/?$/i;
+
+const okUrl = (u) => typeof u === 'string' && /^https?:\/\/\S+$/i.test(u.trim());
+
+// ["emoji", code, url] tags of an event → Map(code → url). Bad codes and
+// non-http urls are dropped; the first of a repeated code wins.
+export function emojiTagMap(tags) {
+ const out = new Map();
+ for (const t of tags || []) {
+ if (!Array.isArray(t) || t[0] !== 'emoji' || t.length < 3) continue;
+ const code = String(t[1] || '');
+ const url = String(t[2] || '').trim();
+ if (!SHORTCODE_RE.test(code) || !okUrl(url) || out.has(code)) continue;
+ out.set(code, url);
+ }
+ return out;
+}
+
+// Split message text into strings and { code, url } emoji parts. `lookup`
+// answers a shortcode with its image url, or nothing to leave it as text.
+export function splitEmoji(text, lookup) {
+ const out = [];
+ const s = String(text || '');
+ if (!s.includes(':')) return s ? [s] : [];
+ for (const part of s.split(EMOJI_TOKEN_RE)) {
+ if (!part) continue;
+ if (part.length > 2 && part[0] === ':' && part[part.length - 1] === ':') {
+ const code = part.slice(1, -1);
+ const url = lookup(code);
+ if (url) { out.push({ code, url }); continue; }
+ }
+ // keep neighbouring text as one node
+ if (out.length && typeof out[out.length - 1] === 'string') out[out.length - 1] += part;
+ else out.push(part);
+ }
+ return out;
+}
+
+// How many custom emoji a message is made of when it's NOTHING but them
+// (plus whitespace) — those render big, like a lone 🎉 does elsewhere.
+// 0 when there's any other text.
+export function emojiOnlyCount(parts) {
+ let n = 0;
+ for (const p of parts) {
+ if (typeof p === 'string') { if (p.trim()) return 0; }
+ else n++;
+ }
+ return n;
+}
+
+// Every shortcode in outbound text that `lookup` resolves → the NIP-30 tags
+// to attach, deduped, in first-use order.
+export function outboundEmojiTags(text, lookup) {
+ const tags = [];
+ const seen = new Set();
+ for (const p of splitEmoji(text, lookup)) {
+ if (typeof p === 'string' || seen.has(p.code)) continue;
+ seen.add(p.code);
+ tags.push(['emoji', p.code, p.url]);
+ }
+ return tags;
+}
+
+// `:code:` reaction content → the code, or null for a plain emoji.
+export function shortcodeOf(content) {
+ const s = String(content || '');
+ return s.length > 2 && s[0] === ':' && s[s.length - 1] === ':' && SHORTCODE_RE.test(s.slice(1, -1)) ? s.slice(1, -1) : null;
+}
+
+export const packAddr = (pubkey, identifier) => `${EMOJI_SET_KIND}:${pubkey}:${identifier}`;
+
+// A pack coordinate → its naddr (no relay hints).
+export function packNaddr(addr) {
+ const [kind, pubkey, ...rest] = String(addr).split(':');
+ try { return naddrEncode({ kind: Number(kind), pubkey, identifier: rest.join(':') }); } catch { return null; }
+}
+
+// Anything a person might paste for a pack — an naddr, `nostr:naddr…`, or
+// Vector's share link — → { kind, pubkey, identifier, relays } or null.
+// Only emoji sets pass: a random naddr must not become a "pack".
+export function parsePackRef(input) {
+ let s = String(input || '').trim();
+ const link = PACK_LINK_RE.exec(s);
+ if (link) s = link[1];
+ s = s.replace(/^nostr:/i, '');
+ if (!/^naddr1[a-z0-9]+$/i.test(s)) return null;
+ try {
+ const d = nip19decode(s.toLowerCase());
+ if (d.type !== 'naddr' || d.data.kind !== EMOJI_SET_KIND) return null;
+ return { kind: d.data.kind, pubkey: d.data.pubkey, identifier: d.data.identifier || '', relays: d.data.relays || [] };
+ } catch { return null; }
+}
+
+// `30030:<pubkey>:<d>` as found in a kind-10030 `a` tag → the same shape.
+export function parsePackAddr(addr) {
+ const m = /^(\d+):([0-9a-f]{64}):(.*)$/.exec(String(addr || ''));
+ if (!m || Number(m[1]) !== EMOJI_SET_KIND) return null;
+ return { kind: EMOJI_SET_KIND, pubkey: m[2], identifier: m[3], relays: [] };
+}
+
+// A kind-30030 event → { addr, pubkey, identifier, title, image, emojis }
+// or null when it isn't one worth keeping (no d tag, no usable emoji).
+// Ditto/Nostria publish `name`/`picture` where NIP-51 says title/image;
+// both are read, as Vector does.
+export function parseEmojiSet(ev) {
+ if (!ev || ev.kind !== EMOJI_SET_KIND) return null;
+ const first = (...keys) => {
+ for (const k of keys) { const t = (ev.tags || []).find((x) => x[0] === k && x[1]); if (t) return String(t[1]); }
+ return '';
+ };
+ const dTag = (ev.tags || []).find((x) => x[0] === 'd');
+ if (!dTag) return null;
+ const identifier = String(dTag[1] || '');
+ const emojis = [...emojiTagMap(ev.tags).entries()];
+ if (!emojis.length) return null;
+ return {
+ addr: packAddr(ev.pubkey, identifier),
+ pubkey: ev.pubkey, identifier,
+ title: first('title', 'name') || identifier,
+ image: first('image', 'picture') || emojis[0][1],
+ emojis, at: ev.created_at || 0,
+ };
+}