diff options
| author | Adam Soltys <[email protected]> | 2026-09-15 09:38:47 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-15 09:38:47 -0700 |
| commit | dce411ae0a5f4f9475786b3bd4f419db0a371709 (patch) | |
| tree | b71e479843808b1e881d4ba4e053f852ac16e299 | |
| parent | 8f3ef61e4fbaf8c7cf586ed1280fd8f4bdc624ae (diff) | |
| download | coinosv3-dce411ae0a5f4f9475786b3bd4f419db0a371709.tar.gz coinosv3-dce411ae0a5f4f9475786b3bd4f419db0a371709.zip | |
A pack link in chat adds the pack in place; coinos has its own share link.
Tapping "Emoji pack" on a phone opened Vector: the chip was an outbound
link to vectorapp.io, a domain the Vector app owns. The chip is now a
button — one tap adds the pack right here, nothing leaves coinos.
Packs also get a coinos share link, v3.coinos.io/emojis/pack/<naddr>
(copy it from the picker's pack row). The Android app already claims
every v3.coinos.io path, so on a phone that link opens coinos, which adds
the pack once the wallet is open. Pack links from any host render as the
chip; the naddr's kind is what's checked.
Pack changes now save at once (learned shortcodes stay coalesced) — a
remove followed straight away by a navigation was lost to the debounce.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
| -rw-r--r-- | src/emoji.js | 2 | ||||
| -rw-r--r-- | src/features/messages.js | 53 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/style.css | 10 | ||||
| -rw-r--r-- | tools/emoji-test.js | 23 |
5 files changed, 65 insertions, 24 deletions
diff --git a/src/emoji.js b/src/emoji.js index 80ccdd2..01db8bf 100644 --- a/src/emoji.js +++ b/src/emoji.js @@ -26,7 +26,7 @@ export const EMOJI_TOKEN_RE = new RegExp('(:[' + CODE + ']+:)', 'g'); 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; +export const PACK_LINK_RE = /^https?:\/\/[^\s/]+\/emojis\/pack\/(naddr1[a-z0-9]+)\/?$/i; const okUrl = (u) => typeof u === 'string' && /^https?:\/\/\S+$/i.test(u.trim()); diff --git a/src/features/messages.js b/src/features/messages.js index cae2ba7..4b7be5e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -26,7 +26,7 @@ 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, + packAddr, packNaddr, parsePackRef, parsePackAddr, parseEmojiSet, } from '../emoji.js'; import { saveInbox } from '../dm-inbox.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch } from '../recipient-search.js'; @@ -114,10 +114,14 @@ export function messagesFeature(ctx) { } return emojiState; }; + // Learned shortcodes arrive in bursts (a backfill), so those writes are + // coalesced; a pack change is one deliberate act and lands at once — a + // tap-then-navigate must never lose it. let emojiSaveTimer = 0; - const saveEmoji = () => { + const saveEmoji = (now = false) => { clearTimeout(emojiSaveTimer); - emojiSaveTimer = setTimeout(() => { try { wallet.saveFeatureState(EMOJI_STATE, emojiSt()); } catch {} }, 500); + const write = () => { try { wallet.saveFeatureState(EMOJI_STATE, emojiSt()); } catch {} }; + if (now) write(); else emojiSaveTimer = setTimeout(write, 500); }; // code → image url: held packs first (in order), then what's been seen const emojiUrl = (code) => { @@ -179,7 +183,7 @@ export function messagesFeature(ctx) { 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(); + saveEmoji(true); } const hasPack = (addr) => !!emojiSt().packs[addr]; // A pasted naddr / Vector share link, or the Add button on a pack link in @@ -204,7 +208,7 @@ export function messagesFeature(ctx) { delete s.packs[addr]; s.order = s.order.filter((a) => a !== addr); s.unfetched = s.unfetched.filter((a) => a !== addr); - saveEmoji(); + saveEmoji(true); render(); publishEmojiList().catch(() => {}); } @@ -222,7 +226,7 @@ export function messagesFeature(ctx) { 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(); + saveEmoji(true); publishOn(emojiRelays(), evt); } // Adopt the newest kind 10030 on the relays when it's newer than what we @@ -252,7 +256,7 @@ export function messagesFeature(ctx) { } // the list's own order wins over arrival order s.order = want.filter((a) => s.packs[a]); - saveEmoji(); + saveEmoji(true); if (changed) scheduleRepaint(); } catch { emojiSyncAt = 0; } } @@ -277,18 +281,21 @@ export function messagesFeature(ctx) { 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. + // A pack share link (Vector's, or ours) rendered as what it is: one tap + // adds the pack right here. Never an outbound link — on a phone the + // vectorapp.io address belongs to the Vector app, and a tap on it walked + // out of coinos into Vector. 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'))); + return h('button', { + class: 'pack-link', type: 'button', title: url, disabled: have || !!ui.emojiBusy, + onClick: (e) => { e.stopPropagation(); addPack(naddr); }, + }, '🎨 ' + t('msgEmojiPackLink'), ' · ', h('b', {}, have ? t('msgEmojiPackHave') : t('msgEmojiPackAdd'))); } + // our share link for a pack — opens in the coinos app on a phone + const packShareLink = (addr) => APP_BASE + '/emojis/pack/' + packNaddr(addr); // ---- unread ------------------------------------------------------------- // A conversation is unread when its newest message from someone else is @@ -1253,6 +1260,11 @@ export function messagesFeature(ctx) { 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 || '…', + // its share link, ours: a tap on a phone opens the coinos app + h('button', { + type: 'button', class: 'linklike', title: t('msgEmojiPackShare'), + onClick: async () => { try { await navigator.clipboard.writeText(packShareLink(p.addr)); toast(t('copied')); } catch {} }, + }, '⧉'), h('button', { type: 'button', class: 'linklike', title: t('msgEmojiPackRemove'), onClick: () => removePack(p.addr) }, '×'))), ui.emojiPick.addPack ? h('form', { @@ -1631,6 +1643,15 @@ export function messagesFeature(ctx) { // An invite link opened in the browser lands here before the wallet exists. const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null; if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} } + // coinos's own pack share link, /emojis/pack/<naddr> — the Android app + // owns every v3.coinos.io path, so one tapped on a phone lands here, not + // in a browser; added once a wallet is open (init below). + const urlPack = (() => { + if (typeof location === 'undefined') return null; + const m = location.pathname.match(/^\/emojis\/pack\/(naddr1[a-z0-9]+)\/?$/i); + return m && parsePackRef(m[1]) ? m[1] : null; + })(); + if (urlPack) { try { history.replaceState(null, '', '/'); } catch {} } // coinos.io/<username> parity: a claimed name (or a raw npub / hex key) as // the whole URL path deep-links to that profile. Real files never reach the @@ -6557,6 +6578,10 @@ export function messagesFeature(ctx) { loadLinkInvite(urlInvite); setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); } + if (urlPack) { + addPack(urlPack).catch(() => {}); + setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0); + } ui.pubProf = null; // a wallet is open now — its chrome owns the profile window.addEventListener('resize', onViewportResize); window.visualViewport?.addEventListener('resize', onViewportResize); diff --git a/src/i18n.js b/src/i18n.js index 84aa76b..d7781cd 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -207,6 +207,7 @@ const STR = { msgEmojiPackAdd: 'Add', msgEmojiPackHave: 'Added', msgEmojiPackRemove: 'Remove this pack', + msgEmojiPackShare: 'Copy a link to this pack', msgEmojiPackAdded: 'Emoji pack added: {name}', msgEmojiPackBad: "Couldn't find an emoji pack there", msgEmojiPackLink: 'Emoji pack', diff --git a/src/style.css b/src/style.css index f1670ce..bcec4a5 100644 --- a/src/style.css +++ b/src/style.css @@ -1036,9 +1036,13 @@ body::after { .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 pack share link (Vector's or ours) as a chip: one tap adds the pack */ +.pack-link { + display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; font-size: 14px; line-height: 1.3; + border: 1px solid var(--line-strong); border-radius: 10px; background: var(--paper); color: inherit; cursor: pointer; +} +.pack-link:disabled { opacity: 0.7; cursor: default; } +.chat-bubble.me .pack-link { background: transparent; border-color: color-mix(in srgb, currentColor 45%, transparent); } /* 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; } diff --git a/tools/emoji-test.js b/tools/emoji-test.js index 42e72dc..c6efb71 100644 --- a/tools/emoji-test.js +++ b/tools/emoji-test.js @@ -56,8 +56,8 @@ try { 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, + packLink: !!document.querySelector('button.pack-link'), + packLinkLabel: (document.querySelector('button.pack-link') || {}).textContent, loaded: [...document.querySelectorAll('.chat-bubble img.cemoji')].filter((i) => i.complete && i.naturalWidth > 0).length, }; }); @@ -65,7 +65,7 @@ try { 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); + check('the pack share link is a chip that adds', r.packLink && /Add$/.test(r.packLinkLabel), r.packLinkLabel); console.log('\n[autocomplete]'); await page.focus('#msg-draft'); @@ -92,11 +92,11 @@ try { await sleep(300); console.log('\n[adding the pack from its link]'); - await page.evaluate(() => document.querySelector('.pack-link button').click()); + await page.evaluate(() => document.querySelector('button.pack-link').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); + const have = await page.evaluate(() => (document.querySelector('button.pack-link') || {}).textContent); + check('the chip now says Added', /Added$/.test(have || ''), 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); @@ -113,6 +113,17 @@ try { `packs ${JSON.stringify(picker.state.order)} learned ${Object.keys(picker.state.learned || {}).length}`); await page.screenshot({ path: shotPath }); console.log('\nscreenshot:', shotPath); + + console.log('\n[our own share link, /emojis/pack/<naddr>]'); + const naddr = await page.evaluate(() => (document.querySelector('button.pack-link').title.match(/naddr1[a-z0-9]+/) || [])[0]); + await page.evaluate(() => document.querySelector('.emoji-pack button[title*="Remove"]').click()); + await sleep(300); + check('the pack is removed first', await page.evaluate(() => !document.querySelector('.emoji-pack'))); + await page.goto('http://localhost:5231/emojis/pack/' + naddr, { waitUntil: 'domcontentloaded' }); + const routed = await waitText('Emoji pack added', 30000); + if (!routed) { await page.screenshot({ path: shotPath.replace('.png', '-route.png') }); console.log(' page says:', JSON.stringify((await page.evaluate(() => document.body.innerText)).slice(0, 400))); } + check('the route adds the pack on boot', routed); + check('and the address is cleaned up', await page.evaluate(() => location.pathname === '/'), await page.evaluate(() => location.pathname)); } catch (e) { console.log('ERROR', e); ok = false; |
