aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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/features
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/features')
-rw-r--r--src/features/messages.js379
1 files changed, 353 insertions, 26 deletions
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(),