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 | |
| 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')
| -rw-r--r-- | src/dm.js | 5 | ||||
| -rw-r--r-- | src/emoji.js | 151 | ||||
| -rw-r--r-- | src/features/messages.js | 379 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/public-chat.js | 28 | ||||
| -rw-r--r-- | src/style.css | 29 |
6 files changed, 568 insertions, 32 deletions
@@ -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; } |
