diff options
| author | Adam Soltys <[email protected]> | 2026-09-15 09:27:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-15 09:27:45 -0700 |
| commit | c90d0f3c600802b5b4b43959df5fd2d02f78012c (patch) | |
| tree | 0bb36a804eda41415e2d3e871bc6475836f8f99e /src/emoji.js | |
| parent | feadbb008de9b2fe2332a7f0e190955ae545f07d (diff) | |
| download | coinosv3-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.js | 151 |
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, + }; +} |
