aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-15 09:38:47 -0700
committerAdam Soltys <[email protected]>2026-09-15 09:38:47 -0700
commitdce411ae0a5f4f9475786b3bd4f419db0a371709 (patch)
treeb71e479843808b1e881d4ba4e053f852ac16e299 /src
parent8f3ef61e4fbaf8c7cf586ed1280fd8f4bdc624ae (diff)
downloadcoinosv3-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
Diffstat (limited to 'src')
-rw-r--r--src/emoji.js2
-rw-r--r--src/features/messages.js53
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css10
4 files changed, 48 insertions, 18 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; }