aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/dm.js5
-rw-r--r--src/emoji.js151
-rw-r--r--src/features/messages.js379
-rw-r--r--src/i18n.js8
-rw-r--r--src/public-chat.js28
-rw-r--r--src/style.css29
-rw-r--r--tools/emoji-test.js113
7 files changed, 681 insertions, 32 deletions
diff --git a/src/dm.js b/src/dm.js
index 1e568a7..c610aa3 100644
--- a/src/dm.js
+++ b/src/dm.js
@@ -72,12 +72,13 @@ export function makeDMRumor(senderPk, peerPk, text, extraTags = []) {
// A reaction to a DM: a kind 7 rumor riding the same gift-wrap envelope
// (the shape 0xchat and friends use), e-tagging the reacted message.
-export function makeDMReaction(senderPk, peerPk, targetId, emoji) {
+// extraTags: a NIP-30 ["emoji", code, url] when the reaction is a :code:
+export function makeDMReaction(senderPk, peerPk, targetId, emoji, extraTags = []) {
const rumor = {
kind: 7,
pubkey: senderPk,
content: emoji,
- tags: [['p', peerPk], ['e', targetId]],
+ tags: [['p', peerPk], ['e', targetId], ...extraTags],
created_at: now(),
};
rumor.id = getEventHash(rumor);
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,
+ };
+}
diff --git a/src/features/messages.js b/src/features/messages.js
index d3caafe..3e45b34 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -23,6 +23,11 @@ import {
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js';
+import {
+ EMOJI_SET_KIND, EMOJI_LIST_KIND, EMOJI_PARTIAL_RE, PACK_LINK_RE,
+ emojiTagMap, splitEmoji, emojiOnlyCount, outboundEmojiTags, shortcodeOf,
+ packAddr, parsePackRef, parsePackAddr, parseEmojiSet,
+} from '../emoji.js';
import { saveInbox } from '../dm-inbox.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js';
import { getNetwork } from '../api.js';
@@ -87,6 +92,204 @@ export function messagesFeature(ctx) {
const communities = () => [COMMUNITY, ...st().communities];
+ // ---- custom emoji (NIP-30 tags, NIP-51 emoji sets) ----------------------
+ // A message that says :pika_wave: carries an ["emoji", code, url] tag for
+ // it, so RENDERING never needs anything held locally. Two sources feed
+ // what you can type and pick: packs you hold (kind 30030 sets, listed in
+ // your kind 10030 — the list Vector reads and writes too, so a pack added
+ // in either app shows in both) and shortcodes LEARNED from messages seen,
+ // so anyone can answer a :pika_wave: with one.
+ const EMOJI_STATE = 'emoji';
+ const LEARNED_MAX = 400;
+ let emojiState = null;
+ const emojiSt = () => {
+ if (!emojiState) {
+ const s = wallet.loadFeatureState(EMOJI_STATE, {}) || {};
+ s.packs ||= {}; // { [addr]: { title, image, emojis: [[code, url]], at } }
+ s.order ||= []; // pack addrs in picker order
+ s.learned ||= {}; // { [code]: url }, insertion-ordered, oldest first
+ s.list ||= { at: 0, tags: [] }; // our kind 10030 as last seen or published
+ s.unfetched ||= []; // listed packs no relay answered for — kept on the list
+ emojiState = s;
+ }
+ return emojiState;
+ };
+ let emojiSaveTimer = 0;
+ const saveEmoji = () => {
+ clearTimeout(emojiSaveTimer);
+ emojiSaveTimer = setTimeout(() => { try { wallet.saveFeatureState(EMOJI_STATE, emojiSt()); } catch {} }, 500);
+ };
+ // code → image url: held packs first (in order), then what's been seen
+ const emojiUrl = (code) => {
+ const s = emojiSt();
+ for (const addr of s.order) {
+ const p = s.packs[addr];
+ if (!p) continue;
+ for (const [c, u] of p.emojis) if (c === code) return u;
+ }
+ return s.learned[code] || null;
+ };
+ // everything typeable: [{ code, url, pack }], packs first, newest-seen next
+ function customEmojis() {
+ const s = emojiSt();
+ const out = [];
+ const seen = new Set();
+ for (const addr of s.order) {
+ const p = s.packs[addr];
+ if (!p) continue;
+ for (const [code, url] of p.emojis) {
+ if (seen.has(code)) continue;
+ seen.add(code);
+ out.push({ code, url, pack: p.title });
+ }
+ }
+ for (const [code, url] of Object.entries(s.learned).reverse()) {
+ if (seen.has(code)) continue;
+ seen.add(code);
+ out.push({ code, url, pack: '' });
+ }
+ return out;
+ }
+ // Remember the emoji tags a message (or reaction) arrived with. Bounded:
+ // the oldest fall off, and re-seeing one moves it back to the front.
+ function noteEmoji(rumor) {
+ const m = emojiTagMap(rumor && rumor.tags);
+ if (!m.size) return;
+ const s = emojiSt();
+ let changed = false;
+ for (const [code, url] of m) {
+ if (s.learned[code] === url) continue;
+ delete s.learned[code];
+ s.learned[code] = url;
+ changed = true;
+ }
+ if (!changed) return;
+ const keys = Object.keys(s.learned);
+ for (const k of keys.slice(0, Math.max(0, keys.length - LEARNED_MAX))) delete s.learned[k];
+ saveEmoji();
+ }
+ const emojiRelays = (extra = []) => [...new Set([...extra, ...zapRelays(), ...DM_RELAYS, ...STOCK_RELAYS])];
+ async function fetchPack(ref) {
+ const evs = await queryOn(emojiRelays(ref.relays), { kinds: [EMOJI_SET_KIND], authors: [ref.pubkey], '#d': [ref.identifier] }, 6000).catch(() => []);
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ return newest ? parseEmojiSet(newest) : null;
+ }
+ function holdPack(pack) {
+ const s = emojiSt();
+ s.packs[pack.addr] = { title: pack.title, image: pack.image, emojis: pack.emojis, at: pack.at };
+ if (!s.order.includes(pack.addr)) s.order.push(pack.addr);
+ s.unfetched = s.unfetched.filter((a) => a !== pack.addr);
+ saveEmoji();
+ }
+ const hasPack = (addr) => !!emojiSt().packs[addr];
+ // A pasted naddr / Vector share link, or the Add button on a pack link in
+ // chat: fetch the set, hold it, and put it on our list.
+ async function addPack(input) {
+ const ref = parsePackRef(input);
+ if (!ref) { toast(t('msgEmojiPackBad')); return false; }
+ const addr = packAddr(ref.pubkey, ref.identifier);
+ if (hasPack(addr)) { toast(t('msgEmojiPackHave')); return true; }
+ ui.emojiBusy = true; render();
+ const pack = await fetchPack(ref);
+ ui.emojiBusy = false;
+ if (!pack) { toast(t('msgEmojiPackBad')); render(); return false; }
+ holdPack(pack);
+ toast(t('msgEmojiPackAdded', { name: pack.title }));
+ render();
+ publishEmojiList().catch(() => {});
+ return true;
+ }
+ function removePack(addr) {
+ const s = emojiSt();
+ delete s.packs[addr];
+ s.order = s.order.filter((a) => a !== addr);
+ s.unfetched = s.unfetched.filter((a) => a !== addr);
+ saveEmoji();
+ render();
+ publishEmojiList().catch(() => {});
+ }
+ // a plain (unwrapped) event signed by the chat identity
+ const signPlain = (id, evt) => (id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : id.signer.signEvent(evt));
+ // Our kind 10030: the packs held as `a` tags. Every other tag of the list
+ // we last saw rides along (inline emoji, whatever another client keeps
+ // there), so publishing from here never strips what Vector wrote.
+ async function publishEmojiList() {
+ const id = await identity();
+ if (!id) return;
+ const s = emojiSt();
+ const kept = (s.list.tags || []).filter((x) => x[0] !== 'a');
+ const created_at = Math.max(Math.floor(Date.now() / 1000), (s.list.at || 0) + 1);
+ const tags = [...kept, ...[...new Set([...s.order, ...s.unfetched])].map((a) => ['a', a])];
+ const evt = await signPlain(id, { kind: EMOJI_LIST_KIND, content: '', tags, created_at });
+ s.list = { at: created_at, tags };
+ saveEmoji();
+ publishOn(emojiRelays(), evt);
+ }
+ // Adopt the newest kind 10030 on the relays when it's newer than what we
+ // hold: packs it lists arrive, packs it dropped go. Ten-minute throttle.
+ let emojiSyncAt = 0;
+ async function syncEmoji({ force = false } = {}) {
+ if (!force && Date.now() - emojiSyncAt < 10 * 60_000) return;
+ emojiSyncAt = Date.now();
+ const pks = myPubkeys();
+ if (!pks.length) return;
+ try {
+ const evs = await queryOn(emojiRelays(), { kinds: [EMOJI_LIST_KIND], authors: pks }, 5000);
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const s = emojiSt();
+ if (!newest || newest.created_at <= (s.list.at || 0)) return;
+ const want = (newest.tags || []).filter((x) => x[0] === 'a' && parsePackAddr(x[1])).map((x) => x[1]);
+ s.list = { at: newest.created_at, tags: newest.tags || [] };
+ s.order = s.order.filter((a) => want.includes(a));
+ for (const a of Object.keys(s.packs)) if (!s.order.includes(a)) delete s.packs[a];
+ s.unfetched = [];
+ saveEmoji();
+ let changed = false;
+ for (const addr of want) {
+ if (s.packs[addr]) continue;
+ const pack = await fetchPack(parsePackAddr(addr));
+ if (pack) { holdPack(pack); changed = true; } else s.unfetched.push(addr);
+ }
+ // the list's own order wins over arrival order
+ s.order = want.filter((a) => s.packs[a]);
+ saveEmoji();
+ if (changed) scheduleRepaint();
+ } catch { emojiSyncAt = 0; }
+ }
+ const emojiImg = (code, url, cls = 'cemoji') =>
+ h('img', { class: cls, src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
+ // A reaction's content as shown on its chip: the picture for a :code: we
+ // know (its own tag taught it to us), the text otherwise.
+ function reactNode(emoji) {
+ const code = shortcodeOf(emoji);
+ const url = code && emojiUrl(code);
+ return url ? emojiImg(code, url) : emoji;
+ }
+ // the NIP-30 tag a :code: reaction travels with
+ const reactEmojiTags = (emoji) => {
+ const code = shortcodeOf(emoji);
+ const url = code && emojiUrl(code);
+ return url ? [['emoji', code, url]] : [];
+ };
+ // a bubble that is nothing but one to three custom emoji shows them big
+ const emojiJumbo = (text, em) => {
+ if (!em || !em.size) return false;
+ const n = emojiOnlyCount(splitEmoji(text, (c) => em.get(c)));
+ return n > 0 && n <= 3;
+ };
+ // Vector's pack share link, rendered as what it is — with an Add button.
+ function packLinkNode(url, naddr) {
+ const ref = parsePackRef(naddr);
+ const addr = ref && packAddr(ref.pubkey, ref.identifier);
+ const have = !!addr && hasPack(addr);
+ return h('span', { class: 'pack-link' },
+ h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, '🎨 ' + t('msgEmojiPackLink')),
+ h('button', {
+ class: 'btn-sm', type: 'button', disabled: have || !!ui.emojiBusy,
+ onClick: (e) => { e.stopPropagation(); addPack(naddr); },
+ }, have ? t('msgEmojiPackHave') : t('msgEmojiPackAdd')));
+ }
+
// ---- unread -------------------------------------------------------------
// A conversation is unread when its newest message from someone else is
// newer than the last one we looked at. The watermark is the rumor's own
@@ -854,6 +1057,7 @@ export function messagesFeature(ctx) {
scheduleRepaint();
return;
}
+ noteEmoji(rumor); // custom emoji seen here become custom emoji you can send
if (rumor.kind === 9) {
const msgs = room.byChannel.get(channelId) || room.byChannel.set(channelId, new Map()).get(channelId);
msgs.set(rumor.id, { rumor, author });
@@ -946,6 +1150,7 @@ export function messagesFeature(ctx) {
// Clear the live element too.
function clearDraft(key) {
setDraft(key, '');
+ ui.emojiAc = null;
const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
}
@@ -964,9 +1169,12 @@ export function messagesFeature(ctx) {
// by replyQuote on every device that has the original)
const replyTo = ui.msgReplyTo && room.byChannel.get(chId)?.has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
+ ui.emojiAc = null;
+ // every :code: we can resolve travels with its picture (NIP-30), so the
+ // room renders it whether or not anyone else holds the pack
const rumor = rumorWithId({
kind: 9, pubkey: id.pubkey, content: text,
- tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ...(replyTo ? [['e', replyTo]] : []), ms], created_at,
+ tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl), ms], created_at,
});
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
@@ -1007,11 +1215,30 @@ export function messagesFeature(ctx) {
const noteRecent = (e) => { try { localStorage.setItem(RECENT_KEY, JSON.stringify([e, ...recentEmojis().filter((x) => x !== e)].slice(0, 24))); } catch {} };
// The reaction row for a sheet: recents lead the quick row, and the last
// button opens the full picker in place — a search box and the grid.
+ // A picker entry: `e` is what gets sent (a character, or `:code:` for a
+ // custom emoji, which then carries `url`), `k` what search matches.
+ const emojiEntry = (e) => {
+ const code = shortcodeOf(e);
+ if (!code) return { e, k: '' };
+ const url = emojiUrl(code);
+ return url ? { e, k: code.toLowerCase(), url } : null; // a recent whose pack is gone
+ };
+ const emojiFace = (x) => (x.url ? emojiImg(shortcodeOf(x.e), x.url) : x.e);
function reactRow(myReact, onPick) {
const pick = (e) => { ui.emojiPick = null; noteRecent(e); onPick(e); };
if (ui.emojiPick) {
const q = (ui.emojiPick.q || '').trim().toLowerCase();
- const hits = q ? EMOJI_LIB.filter((x) => x.k.includes(q) || x.e === q) : [...new Set([...recentEmojis(), ...EMOJI_LIB.map((x) => x.e)])].map((e) => ({ e }));
+ // custom emoji lead — packs you hold, then ones you've seen — and the
+ // standard set follows; a search matches shortcodes and keywords alike
+ const custom = customEmojis().map((x) => ({ e: ':' + x.code + ':', k: x.code.toLowerCase(), url: x.url }));
+ const std = EMOJI_LIB;
+ let hits;
+ if (q) hits = [...custom.filter((x) => x.k.includes(q)), ...std.filter((x) => x.k.includes(q) || x.e === q)];
+ else hits = [...recentEmojis().map(emojiEntry).filter(Boolean), ...custom, ...std];
+ const seen = new Set();
+ hits = hits.filter((x) => !seen.has(x.e) && seen.add(x.e));
+ const s = emojiSt();
+ const packs = s.order.map((addr) => ({ addr, ...s.packs[addr] })).filter((p) => p.title !== undefined);
return h('div', { class: 'col', style: 'gap:8px' },
h('input', {
type: 'text', placeholder: t('msgEmojiSearch'), value: ui.emojiPick.q || '', autofocus: true,
@@ -1019,12 +1246,30 @@ export function messagesFeature(ctx) {
onKeydown: (e) => { if (e.key === 'Enter' && hits.length) pick(hits[0].e); if (e.key === 'Escape') { ui.emojiPick = null; render(); } },
}),
h('div', { class: 'emoji-grid' },
- hits.slice(0, 160).map((x) => h('button', { class: x.e === myReact ? 'on' : '', title: x.k || '', onClick: () => pick(x.e) }, x.e)),
- !hits.length ? h('div', { class: 'small muted', style: 'padding:8px' }, t('msgEmojiNone')) : null));
+ hits.slice(0, 200).map((x) => h('button', { class: x.e === myReact ? 'on' : '', title: x.url ? x.e : x.k, onClick: () => pick(x.e) }, emojiFace(x))),
+ !hits.length ? h('div', { class: 'small muted', style: 'padding:8px' }, t('msgEmojiNone')) : null),
+ // the packs behind the custom ones, and the way to add another —
+ // Vector's share link or a bare naddr, both land the same set
+ h('div', { class: 'emoji-packs' },
+ packs.map((p) => h('span', { class: 'chat-react emoji-pack', title: p.addr },
+ p.image ? h('img', { class: 'cemoji', src: p.image, alt: '' }) : null, ' ', p.title || '…',
+ h('button', { type: 'button', class: 'linklike', title: t('msgEmojiPackRemove'), onClick: () => removePack(p.addr) }, '×'))),
+ ui.emojiPick.addPack
+ ? h('form', {
+ class: 'row gap6', style: 'width:100%',
+ onSubmit: async (e) => {
+ e.preventDefault();
+ const inp = e.target.querySelector('input');
+ if (await addPack(inp.value)) { ui.emojiPick.addPack = false; render(); }
+ },
+ },
+ h('input', { type: 'text', class: 'grow', placeholder: t('msgEmojiPackHint'), autofocus: true, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false' }),
+ h('button', { class: 'btn-sm', type: 'submit', disabled: !!ui.emojiBusy }, ui.emojiBusy ? h('span', { class: 'spinner sm' }) : t('msgEmojiPackAdd')))
+ : h('button', { type: 'button', class: 'linklike small', onClick: () => { ui.emojiPick.addPack = true; render(); } }, '+ ' + t('msgEmojiAddPack'))));
}
- const quick = [...new Set([...recentEmojis(), ...REACT_EMOJIS])].slice(0, 6);
+ const quick = [...new Set([...recentEmojis(), ...REACT_EMOJIS])].map(emojiEntry).filter(Boolean).slice(0, 6);
return h('div', { class: 'msg-sheet-emojis' },
- quick.map((e2) => h('button', { class: e2 === myReact ? 'on' : '', onClick: () => pick(e2) }, e2)),
+ quick.map((x) => h('button', { class: x.e === myReact ? 'on' : '', onClick: () => pick(x.e) }, emojiFace(x))),
h('button', { class: 'more', title: t('msgEmojiMore'), onClick: () => { ui.emojiPick = { q: '' }; render(); } }, '+'));
}
async function sendReaction(room, chId, m, emoji) {
@@ -1034,7 +1279,7 @@ export function messagesFeature(ctx) {
const { created_at, ms } = msTags(Date.now());
const rumor = rumorWithId({
kind: 7, pubkey: id.pubkey, content: emoji,
- tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ['e', m.rumor.id], ['k', '9'], ms], created_at,
+ tags: [['channel', chId], ['epoch', String(room.chEpoch(chId))], ['e', m.rumor.id], ['k', '9'], ...reactEmojiTags(emoji), ms], created_at,
});
const r = room.reactions.get(m.rumor.id) || room.reactions.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
@@ -2047,11 +2292,13 @@ export function messagesFeature(ctx) {
const mine = isMe(got.author);
const to = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
const peer = mine ? (to || got.peer || got.author) : got.author;
+ noteEmoji(got.rumor);
noteDM(peer, got.rumor, mine);
persistDms();
} else if (got.rumor.kind === 7) {
// a DM reaction (ours echoed back, or the peer's — 0xchat's shape)
const target = got.rumor.tags?.find((x) => x[0] === 'e')?.[1];
+ noteEmoji(got.rumor);
if (target) {
(dmReacts.get(target) || dmReacts.set(target, new Map()).get(target)).set(got.author, got.rumor.content);
scheduleRepaint();
@@ -2152,7 +2399,10 @@ export function messagesFeature(ctx) {
continue;
}
for (const m of list)
- threadOf(peer).set(m.id, { rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14 }, mine: isMe(m.from) });
+ threadOf(peer).set(m.id, {
+ rumor: { id: m.id, pubkey: m.from, content: m.text, created_at: m.t, kind: 14, tags: (m.em || []).map(([c, u]) => ['emoji', c, u]) },
+ mine: isMe(m.from),
+ });
}
bumpMsgRev();
if (swept) save(s);
@@ -2210,7 +2460,8 @@ export function messagesFeature(ctx) {
// echo folds into this entry instead of duplicating it.
const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null;
ui.msgReplyTo = null;
- const rumor = makeDMRumor(id.pubkey, peer, text, replyTo ? [['e', replyTo]] : []);
+ ui.emojiAc = null;
+ const rumor = makeDMRumor(id.pubkey, peer, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]);
const entry = { rumor, mine: true, pending: true };
threadOf(peer).set(rumor.id, entry);
clearDraft('dm:' + peer);
@@ -2247,7 +2498,7 @@ export function messagesFeature(ctx) {
if (!id) { noIdToast(); return; }
if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; }
ui.msgSheet = null;
- const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji);
+ const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji, reactEmojiTags(emoji));
const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id);
const prev = r.get(id.pubkey);
r.set(id.pubkey, emoji);
@@ -2279,7 +2530,13 @@ export function messagesFeature(ctx) {
s.dms = {};
for (const [peer, list] of byRecent)
s.dms[peer] = list.filter((m) => !m.pending).slice(-CACHE_MAX)
- .map((m) => ({ id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }));
+ .map((m) => {
+ const row = { id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at };
+ // a message's custom emoji ride along, or they'd be text after a reload
+ const em = [...emojiTagMap(m.rumor.tags)];
+ if (em.length) row.em = em;
+ return row;
+ });
save(s);
}
@@ -3468,7 +3725,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
// one level deep only: a quote of a quote of a quote is a rabbit
// hole, and the inner one stays a link you can follow
- ...noteBody(ev.content, depth + 1)));
+ ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))));
}
// One URL, rendered as whatever it points at. `isImage` is markdown saying
@@ -3719,8 +3976,12 @@ export function messagesFeature(ctx) {
ui.msgUploading = false; render();
}
- function noteBody(text, depth = 0) {
+ // `em` is the event's own ["emoji", code, url] map (NIP-30): a :code: it
+ // names becomes its picture, any other :code: stays text — a message
+ // never borrows pictures from packs the READER happens to hold.
+ function noteBody(text, depth = 0, em = null) {
const out = [];
+ const emojiAt = em && em.size ? (c) => em.get(c) : null;
for (const part of String(text || '').split(NOTE_SPLIT)) {
if (!part) continue;
const md = MD_PARTS.exec(part);
@@ -3728,7 +3989,8 @@ export function messagesFeature(ctx) {
const [, bang, label, url] = md;
out.push(urlNode(url, { label: label || null, isImage: !!bang }));
} else if (/^https?:\/\//i.test(part)) {
- out.push(urlNode(part));
+ const pack = PACK_LINK_RE.exec(part);
+ out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
} else if (/^nostr:(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk)));
@@ -3749,6 +4011,8 @@ export function messagesFeature(ctx) {
href: '#', title: t('searchPeopleFor', { q: part.slice(1) }),
onClick: (e) => { e.preventDefault(); e.stopPropagation(); openPeopleSearch(part.slice(1)); },
}, part));
+ } else if (emojiAt && part.includes(':')) {
+ for (const p of splitEmoji(part, emojiAt)) out.push(typeof p === 'string' ? p : emojiImg(p.code, p.url));
} else out.push(part);
}
return out;
@@ -4164,7 +4428,7 @@ export function messagesFeature(ctx) {
style: 'flex-shrink:0',
onClick: (e) => { e.stopPropagation(); ui.noteSheet = ev; render(); },
}, '\u22ef')),
- h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)),
+ h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))),
pending ? null : noteActions(pk, ev, { canZap }),
!pending && whoOpen(ev.id) ? whoPanel(ev) : null));
}
@@ -5185,9 +5449,59 @@ export function messagesFeature(ctx) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
};
- const composer = (placeholder, onSend, onType, draftKey, onAttach = null) =>
- h('div', { class: 'col', style: 'gap:6px' },
+ // ---- :shortcode: autocomplete in the composer -----------------------------
+ // A colon and two letters at the caret open a strip of matches above the
+ // box: custom emoji first (packs held, then ones seen here), a few
+ // standard ones after. Tap, Tab or Enter takes the first; Escape closes.
+ function updateEmojiAc(el, key) {
+ const upto = el.value.slice(0, el.selectionEnd ?? el.value.length);
+ const m = EMOJI_PARTIAL_RE.exec(upto);
+ const next = m ? { key, q: m[1], start: upto.length - m[1].length - 1, end: upto.length } : null;
+ const was = ui.emojiAc;
+ ui.emojiAc = next;
+ if (!!was !== !!next || (was && next && (was.q !== next.q || was.key !== next.key))) render();
+ }
+ function emojiAcHits(q) {
+ const ql = q.toLowerCase();
+ const custom = customEmojis().filter((x) => x.code.toLowerCase().includes(ql))
+ .sort((a, b) => Number(b.code.toLowerCase().startsWith(ql)) - Number(a.code.toLowerCase().startsWith(ql)));
+ const std = EMOJI_LIB.filter((x) => x.k.split(' ').some((w) => w.startsWith(ql)));
+ return [...custom.slice(0, 8), ...std.slice(0, 4)].slice(0, 8);
+ }
+ function pickEmojiAc(hit) {
+ const ac = ui.emojiAc;
+ ui.emojiAc = null;
+ const el = document.getElementById('msg-draft');
+ if (!ac || !el) { render(); return; }
+ const ins = hit.url ? ':' + hit.code + ': ' : hit.e + ' ';
+ const v = el.value;
+ el.value = v.slice(0, ac.start) + ins + v.slice(ac.end);
+ el.selectionStart = el.selectionEnd = ac.start + ins.length;
+ setDraft(ac.key, el.value);
+ growComposer(el);
+ if (hit.url) noteRecent(':' + hit.code + ':');
+ render();
+ el.focus();
+ }
+ // Always a node, empty when idle: the strip appearing must not shift the
+ // composer's children, or the morph rebuilds the textarea under a
+ // typing finger and the keystroke that opened it lands nowhere.
+ function emojiAcStrip(draftKey) {
+ const ac = ui.emojiAc;
+ const hits = ac && ac.key === draftKey ? emojiAcHits(ac.q) : [];
+ return h('div', { class: 'emoji-ac' },
+ hits.map((x) => h('button', {
+ type: 'button', title: x.url ? ':' + x.code + ':' : x.k,
+ onMousedown: (e) => e.preventDefault(), // the box keeps focus and its caret
+ onClick: () => pickEmojiAc(x),
+ }, x.url ? emojiImg(x.code, x.url) : x.e, x.url ? h('span', { class: 'small' }, x.code) : null)));
+ }
+
+ const composer = (placeholder, onSend, onType, draftKey, onAttach = null) => {
+ syncEmoji().catch(() => {}); // throttled inside: your packs, from wherever you added them
+ return h('div', { class: 'col', style: 'gap:6px' },
signerNotice(),
+ emojiAcStrip(draftKey),
h('div', { class: 'chat-compose' },
onAttach ? h('button', {
class: 'iconbtn chat-clip', title: t('msgAttach'), 'aria-label': t('msgAttach'),
@@ -5201,8 +5515,16 @@ export function messagesFeature(ctx) {
h('textarea', {
class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
value: draftFor(draftKey), maxlength: '2000',
- onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); if (onType && e.target.value) onType(); },
+ onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); updateEmojiAc(e.target, draftKey); if (onType && e.target.value) onType(); },
+ onClick: (e) => updateEmojiAc(e.target, draftKey), // the caret moved
+ onBlur: () => { if (ui.emojiAc) { ui.emojiAc = null; render(); } },
onKeydown: (e) => {
+ const ac = ui.emojiAc && ui.emojiAc.key === draftKey ? ui.emojiAc : null;
+ if (ac && (e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) {
+ const hits = emojiAcHits(ac.q);
+ if (hits.length) { e.preventDefault(); pickEmojiAc(hits[0]); return; }
+ }
+ if (ac && e.key === 'Escape') { e.preventDefault(); ui.emojiAc = null; render(); return; }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
@@ -5219,6 +5541,7 @@ export function messagesFeature(ctx) {
onFocus: () => { stickToBottom(); setTimeout(stickToBottom, 350); },
}),
h('button', { class: 'btn-primary btn-sm', onClick: onSend }, t('msgSend'))));
+ };
// ---- home ---------------------------------------------------------------
@@ -5500,6 +5823,8 @@ export function messagesFeature(ctx) {
// choosing another emoji replaces it (one reaction per author, the fold
// keeps the latest)
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
+ // the message's own custom emoji (an edit brings its own set)
+ const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags);
return h(
'div', { class: 'chat-row' + (mine ? ' mine' : '') + (grouped ? ' grouped' : '') },
grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.author),
@@ -5513,13 +5838,15 @@ export function messagesFeature(ctx) {
m.author === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
- class: 'chat-bubble clickable',
+ class: 'chat-bubble clickable' + (emojiJumbo(text, em) ? ' jumbo' : ''),
// Telegram-style: a tap on the message opens its action sheet
// (quick reactions, reply, copy, delete). Links, images and the
// hover × keep their own clicks; a desktop text-selection drag
// ends in a click too, and must not pop the sheet over the copy.
+ // A custom emoji is an <img> too, but tapping it should open the
+ // sheet like tapping any other word.
onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img')) return;
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
const sel = window.getSelection && window.getSelection();
if (sel && String(sel).length) return;
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
@@ -5527,7 +5854,7 @@ export function messagesFeature(ctx) {
},
},
replyQuote(room, chId, m),
- ...noteBody(text),
+ ...noteBody(text, 0, em),
...attachmentNodes(m.rumor),
edit ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null,
mine
@@ -5543,7 +5870,7 @@ export function messagesFeature(ctx) {
class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''),
title: t('msgReact'),
onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); },
- }, emoji, n > 1 ? ' ' + n : '')))
+ }, reactNode(emoji), n > 1 ? ' ' + n : '')))
: null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: !mine && canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null }))))
);
});
@@ -5759,7 +6086,7 @@ export function messagesFeature(ctx) {
[...counts.entries()].map(([emoji, n]) => h('span', {
class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''),
onClick: (e) => { e.stopPropagation(); sendDmReaction(peer, m, emoji); },
- }, emoji, n > 1 ? ' ' + n : '')));
+ }, reactNode(emoji), n > 1 ? ' ' + n : '')));
};
const dmReplyBar = () => {
const m = ui.msgReplyTo && thread.get(ui.msgReplyTo);
@@ -5789,16 +6116,16 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
h('div', { class: 'chat-body' },
h('div', {
- class: 'chat-bubble clickable' + (m.mine ? ' me' : ''),
+ class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img')) return;
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
const sel = window.getSelection && window.getSelection();
if (sel && String(sel).length) return;
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
},
- }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content)), ...attachmentNodes(m.rumor), dmChips(m)),
+ }, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor), dmChips(m)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
dmReplyBar(),
diff --git a/src/i18n.js b/src/i18n.js
index 6a51fef..84aa76b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -202,6 +202,14 @@ const STR = {
msgEmojiSearch: 'Search emoji…',
msgEmojiMore: 'More reactions',
msgEmojiNone: 'No match',
+ msgEmojiAddPack: 'Add an emoji pack',
+ msgEmojiPackHint: 'Paste a pack link or naddr…',
+ msgEmojiPackAdd: 'Add',
+ msgEmojiPackHave: 'Added',
+ msgEmojiPackRemove: 'Remove this pack',
+ msgEmojiPackAdded: 'Emoji pack added: {name}',
+ msgEmojiPackBad: "Couldn't find an emoji pack there",
+ msgEmojiPackLink: 'Emoji pack',
msgEdited: '(edited)',
msgReact: 'React',
msgZap: 'Zap',
diff --git a/src/public-chat.js b/src/public-chat.js
index 0cdadc4..4d79010 100644
--- a/src/public-chat.js
+++ b/src/public-chat.js
@@ -18,6 +18,7 @@ import { COMMUNITY, EPOCH } from './community.js';
import { hexToBytes } from '@noble/hashes/utils';
import { timeAgo } from './format.js';
import { t } from './i18n.js';
+import { emojiTagMap, splitEmoji, emojiOnlyCount, shortcodeOf } from './emoji.js';
// Same shape as the app's h() for the attributes this page uses.
function h(tag, attrs = {}, ...children) {
@@ -40,11 +41,18 @@ function h(tag, attrs = {}, ...children) {
// richer noteBody also resolves nostr: mentions — here they stay dim stubs,
// since this page has no profile screens to open.
const LINK_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi;
-function linkedBody(text) {
+// a custom emoji (NIP-30): the picture a message's own emoji tag names
+const emojiImg = (code, url) => h('img', { class: 'cemoji', src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
+// `em` is the message's ["emoji", code, url] map; a :code: it names renders
+// as its picture, anything else stays text
+function linkedBody(text, em = null) {
const out = [];
+ const emojiAt = em && em.size ? (c) => em.get(c) : null;
for (const part of String(text || '').split(LINK_SPLIT)) {
if (!part) continue;
- if (/^https?:\/\//i.test(part)) {
+ if (emojiAt && !/^(https?:\/\/|nostr:)/i.test(part) && part.includes(':')) {
+ for (const p of splitEmoji(part, emojiAt)) out.push(typeof p === 'string' ? p : emojiImg(p.code, p.url));
+ } else if (/^https?:\/\//i.test(part)) {
if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) {
out.push(h('img', { src: part, class: 'note-img', loading: 'lazy',
onError: (e) => { e.target.style.display = 'none'; } }));
@@ -69,6 +77,7 @@ export function mountPublicChat() {
const edits = new Map(); // rumorId -> { rumor, author }
const deletes = new Map(); // rumorId -> Set(author)
const reactions = new Map(); // rumorId -> Map(author -> emoji)
+ const reactEmoji = new Map(); // shortcode -> url, from reactions' own emoji tags
const controlEntries = [];
let folded = null; // foldControl result: banned set + channel renames
let foldT = 0; // fold-once-per-burst debounce, same as the app's rooms
@@ -143,6 +152,9 @@ export function mountPublicChat() {
} else if (rumor.kind === 7) {
const target2 = tag('e')?.[1];
if (target2) (reactions.get(target2) || reactions.set(target2, new Map()).get(target2)).set(author, rumor.content);
+ // a :code: reaction names its picture in its own tag; kept by code so
+ // the chip can show it
+ for (const [code, url] of emojiTagMap(rumor.tags)) reactEmoji.set(code, url);
} else {
return;
}
@@ -236,6 +248,14 @@ export function mountPublicChat() {
prev = m;
const edit = edits.get(m.rumor.id);
const content = edit && edit.author === m.author ? edit.rumor.content : m.rumor.content;
+ const em = emojiTagMap((edit && edit.author === m.author ? edit.rumor : m.rumor).tags);
+ const jumboN = em.size ? emojiOnlyCount(splitEmoji(content, (c) => em.get(c))) : 0;
+ const jumbo = jumboN > 0 && jumboN <= 3;
+ const reactNode = (emoji) => {
+ const code = shortcodeOf(emoji);
+ const url = code && reactEmoji.get(code);
+ return url ? emojiImg(code, url) : emoji;
+ };
const reacts = reactions.get(m.rumor.id);
// a reply e-tags its quoted message — render the context when we hold it
const replyId = (m.rumor.tags || []).find((x) => x[0] === 'e')?.[1];
@@ -252,11 +272,11 @@ export function mountPublicChat() {
h('span', { class: 'chat-name' + (m.author === COMMUNITY.owner ? ' owner' : '') }, nameOf(m.author)),
m.author === COMMUNITY.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null,
h('span', { class: 'chat-time' }, timeAgo(ms / 1000))),
- h('div', { class: 'chat-bubble' }, quote, ...linkedBody(content),
+ h('div', { class: 'chat-bubble' + (jumbo ? ' jumbo' : '') }, quote, ...linkedBody(content, em),
edit && edit.author === m.author ? h('span', { class: 'chat-edited' }, ' ' + t('msgEdited')) : null,
reacts && reacts.size ? h('div', { class: 'chat-reacts' },
...[...[...reacts.values()].reduce((m2, e) => m2.set(e, (m2.get(e) || 0) + 1), new Map()).entries()]
- .map(([emoji, n]) => h('span', { class: 'chat-react' }, emoji + (n > 1 ? ' ' + n : '')))) : null))));
+ .map(([emoji, n]) => h('span', { class: 'chat-react' }, reactNode(emoji), n > 1 ? ' ' + n : ''))) : null))));
}
if (stick) log.scrollTop = log.scrollHeight;
}
diff --git a/src/style.css b/src/style.css
index 4bb7c5b..f969cee 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1014,3 +1014,32 @@ body::after {
.emoji-grid button.on { border-color: var(--accent); }
.hub-list { max-height: 360px; overflow-y: auto; }
.hub-icon { object-fit: cover; }
+
+/* custom emoji (NIP-30): an inline picture the height of a capital, sat on
+ the baseline like the character it stands for */
+.cemoji { display: inline-block; height: 1.4em; width: auto; max-width: 3em; vertical-align: -0.35em; object-fit: contain; }
+/* a bubble that is nothing but one to three of them shows them big */
+.chat-bubble.jumbo .cemoji { height: 2.8em; max-width: 5em; vertical-align: middle; }
+.chat-react .cemoji { height: 1.25em; vertical-align: -0.3em; }
+.emoji-grid .cemoji { height: 28px; max-width: 36px; vertical-align: middle; }
+.msg-sheet-emojis .cemoji { height: 22px; vertical-align: middle; }
+/* the :shortcode: strip above the composer */
+.emoji-ac { display: flex; flex-wrap: wrap; gap: 4px; }
+.emoji-ac:empty { display: none; }
+.emoji-ac button {
+ display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; font-size: 18px; line-height: 1;
+ border: 1px solid var(--line); border-radius: 10px; background: var(--paper);
+}
+.emoji-ac button .small { font-size: 12px; color: var(--muted); }
+.emoji-ac .cemoji { height: 22px; vertical-align: middle; }
+/* the packs behind the picker, and the add box */
+.emoji-packs { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
+.emoji-pack { display: inline-flex; align-items: center; gap: 2px; }
+.emoji-pack .linklike { padding: 0 2px; font-size: 14px; color: var(--muted); }
+/* Vector's pack share link: named, with its Add button */
+.pack-link { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.pack-link .btn-sm { padding: 3px 10px; }
+/* a custom emoji whose picture won't load falls back to its :code: alt text;
+ in the 40px grid cell that text must not spill into the next cell */
+.emoji-grid button { overflow: hidden; }
+.emoji-grid .cemoji { font-size: 8px; }
diff --git a/tools/emoji-test.js b/tools/emoji-test.js
new file mode 100644
index 0000000..4f9e73e
--- /dev/null
+++ b/tools/emoji-test.js
@@ -0,0 +1,113 @@
+// Custom emoji in the coinos room: Vector's :pika_wave: messages render as
+// pictures, a lone one is jumbo, the :vector_logo: reaction chip is a picture,
+// the composer autocompletes a learned shortcode, and the pack link in chat
+// installs the pack. Read-only: nothing is posted.
+//
+// Run: bun tools/emoji-test.js [screenshot.png]
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+
+const shotPath = process.argv[2] || '/tmp/emoji-test.png';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5231, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; };
+
+try {
+ await page.setViewport({ width: 420, height: 860 });
+ await page.goto('http://localhost:5231/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Import existing');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('button', 'Open wallet');
+ await waitText('receive', 15000);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());
+ await sleep(800);
+ check('the coinos community is listed', await waitText('coinos', 10000));
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-thread-row')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'coinos'); if (e) e.click(); });
+ check('the GM message arrived', await waitText('GM Coinos community', 30000));
+ await sleep(1500);
+
+ console.log('\n[rendering]');
+ const r = await page.evaluate(() => {
+ const bubbles = [...document.querySelectorAll('.chat-bubble')];
+ const gm = bubbles.find((b) => b.textContent.includes('GM Coinos community'));
+ const kirby = bubbles.find((b) => b.querySelector('img.cemoji[alt=":kirbyrainbowglow:"]'));
+ const chips = [...document.querySelectorAll('.chat-react')].filter((c) => c.querySelector('img.cemoji'));
+ return {
+ imgs: document.querySelectorAll('.chat-bubble img.cemoji').length,
+ gmImg: !!(gm && gm.querySelector('img.cemoji[alt=":pika_wave:"]')),
+ gmText: gm ? gm.textContent : '',
+ kirbyJumbo: !!(kirby && kirby.classList.contains('jumbo')),
+ chipImgs: chips.map((c) => c.querySelector('img').alt),
+ packLink: !!document.querySelector('.pack-link button'),
+ packLinkLabel: (document.querySelector('.pack-link button') || {}).textContent,
+ loaded: [...document.querySelectorAll('.chat-bubble img.cemoji')].filter((i) => i.complete && i.naturalWidth > 0).length,
+ };
+ });
+ check('custom emoji render as pictures', r.imgs > 0, `${r.imgs} imgs, ${r.loaded} loaded`);
+ check(':pika_wave: is a picture and not text', r.gmImg && !r.gmText.includes(':pika_wave:'), JSON.stringify(r.gmText));
+ check('a lone :kirbyrainbowglow: is jumbo', r.kirbyJumbo);
+ check('a :vector_logo: reaction chip is a picture', r.chipImgs.includes(':vector_logo:'), JSON.stringify(r.chipImgs));
+ check('the pack share link has an Add button', r.packLink, r.packLinkLabel);
+
+ console.log('\n[autocomplete]');
+ await page.focus('#msg-draft');
+ await page.keyboard.type('hello :pik');
+ await sleep(500);
+ const ac = await page.evaluate(() => [...document.querySelectorAll('.emoji-ac button')].map((b) => b.title));
+ await page.screenshot({ path: shotPath.replace('.png', '-ac.png') });
+ check('typing :pik offers the learned pika emoji', ac.some((x) => x.includes('pika')), JSON.stringify(ac));
+ await page.keyboard.press('Tab');
+ await sleep(400);
+ const draft = await page.evaluate(() => document.querySelector('#msg-draft').value);
+ check('Tab completes the shortcode', /^hello :pika_\w+: $/.test(draft), JSON.stringify(draft));
+ check('the strip closes after the pick', await page.evaluate(() => !document.querySelector('.emoji-ac button')));
+ await page.evaluate(() => { const e = document.querySelector('#msg-draft'); e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); });
+
+ console.log('\n[adding the pack from its link]');
+ await page.evaluate(() => document.querySelector('.pack-link button').click());
+ check('the pack arrives', await waitText('Emoji pack added', 20000));
+ await sleep(500);
+ const have = await page.evaluate(() => (document.querySelector('.pack-link button') || {}).textContent);
+ check('the link now says Added', have === 'Added', have);
+ // the picker lists it
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-bubble')].find((x) => x.textContent.includes('GM Coinos')); b.click(); });
+ await sleep(500);
+ await page.evaluate(() => document.querySelector('.msg-sheet-emojis .more').click());
+ await sleep(500);
+ const picker = await page.evaluate(() => ({
+ custom: document.querySelectorAll('.emoji-grid img.cemoji').length,
+ packs: [...document.querySelectorAll('.emoji-pack')].map((p) => p.textContent.trim()),
+ state: JSON.parse(localStorage.getItem(Object.keys(localStorage).find((k) => k.endsWith(':emoji'))) || '{}'),
+ }));
+ check('the picker shows custom emoji', picker.custom > 5, `${picker.custom}`);
+ check('the pack is listed', picker.packs.length === 1, JSON.stringify(picker.packs));
+ check('state holds the pack + learned shortcodes', picker.state.order && picker.state.order.length === 1 && Object.keys(picker.state.learned || {}).length >= 5,
+ `packs ${JSON.stringify(picker.state.order)} learned ${Object.keys(picker.state.learned || {}).length}`);
+ await page.screenshot({ path: shotPath });
+ console.log('\nscreenshot:', shotPath);
+} catch (e) {
+ console.log('ERROR', e);
+ ok = false;
+}
+await browser.close();
+server.stop(true);
+console.log(ok ? '\nALL GOOD' : '\nFAILURES');
+process.exit(ok ? 0 : 1);