aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 16:13:58 -0700
committerAdam Soltys <[email protected]>2026-10-09 16:13:58 -0700
commit52612bc8bc4c9ea18b0520fc23c98094f304cc05 (patch)
treefc4241766ccb9ab6c9676baa34613df22796f44c
parent3dbd82cafb0fbada4270525bec607ba32645d26b (diff)
downloadcoinosv3-52612bc8bc4c9ea18b0520fc23c98094f304cc05.tar.gz
coinosv3-52612bc8bc4c9ea18b0520fc23c98094f304cc05.zip
@mentions: "@" and a few letters in a post, a reply or an edit open a list of people under the box — the ones you follow whose name matches first (at once, from what's here), then the header's people search. Picking one (tap, Tab or Enter) puts nostr:npub… in the text, shown everywhere as @name (NIP-27), and the post or reply goes out with their p tag so they're told; Escape closes. coinos-text's setSelectionRange walks into the <div>s Chrome wraps lines in after an Enter — it used to put any position past the first line at the end of the field. tools/mention-ac-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--src/features/messages.js98
-rw-r--r--src/rich-text.js38
-rw-r--r--src/style.css4
-rw-r--r--tools/mention-ac-test.js117
4 files changed, 242 insertions, 15 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index d4d5c43..90b080b 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -9103,8 +9103,10 @@ export function messagesFeature(ctx) {
h('coinos-text', {
class: 'post-edit-input', style: 'font-family:var(--sans);min-height:120px;max-height:50vh',
value: e.text,
- onInput: (x) => { e.text = x.target.value; },
+ onInput: (x) => { e.text = x.target.value; updateMentionAc(x.target, '.post-edit-input'); },
+ onKeydown: mentionKey,
}),
+ mentionList('.post-edit-input'),
h('div', { class: 'small faint' }, t('postEditHelp')),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: close }, t('cancel')),
@@ -9637,7 +9639,9 @@ export function messagesFeature(ctx) {
// client render the quoted post inline and tell its author
const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, CLIENT_TAG] };
+ // the people it mentions are told (NIP-27 p tags)
+ const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, CLIENT_TAG] };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -9664,6 +9668,8 @@ export function messagesFeature(ctx) {
...(c.root ? [c.root.pubkey] : []),
...target.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]),
])].filter((pk) => pk !== id.pubkey).slice(0, 8);
+ // and whoever the reply mentions by name (NIP-27), beyond the thread's eight
+ for (const pk of mentionedPks(text)) if (pk !== id.pubkey && !pTags.includes(pk)) pTags.push(pk);
const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url))
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
const root = c.root || (target.id === rootId ? target : null);
@@ -9751,8 +9757,11 @@ export function messagesFeature(ctx) {
}),
// a render per keystroke only while the preview is open; the morph
// leaves a focused field alone, so this can't fight the typing
- onInput: (e) => { s.draft = e.target.value; growComposer(e.target); if (s.preview) render(); },
+ onInput: (e) => { s.draft = e.target.value; growComposer(e.target); updateMentionAc(e.target, '.thread-reply-input'); if (s.preview) render(); },
+ onKeyup: (e) => { if (/^Arrow|^Home|^End/.test(e.key)) updateMentionAc(e.target, '.thread-reply-input'); },
onKeydown: (e) => {
+ mentionKey(e);
+ if (e.defaultPrevented) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
@@ -9765,6 +9774,7 @@ export function messagesFeature(ctx) {
}
},
}),
+ mentionList('.thread-reply-input'),
h('div', { class: 'row reply-tools wrap', style: 'gap:8px;align-items:center' },
// A reply can carry a picture too — same upload, same imeta tag, same
// paperclip. The URL lands in the draft, which is what every client
@@ -10511,15 +10521,19 @@ export function messagesFeature(ctx) {
value: text,
// the field is focused, so the morph leaves it: put the URL in by hand
onMedia: (f) => attachMedia(f).then(() => { const el = document.querySelector('.post-input'); if (el) el.value = composeText(); }),
+ onKeydown: mentionKey,
+ onKeyup: (ev) => { if (/^Arrow|^Home|^End/.test(ev.key)) updateMentionAc(ev.target, '.post-input'); },
onInput: (ev) => {
ui.profCompose = ev.target.value;
setDraft(POST_DRAFT, ev.target.value);
+ updateMentionAc(ev.target, '.post-input');
// only while the preview is open — otherwise typing costs a render
// per keystroke for nothing. The morph never rewrites a focused
// field, so this can't fight the typing.
if (ui.postPreview) render();
},
}),
+ mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
h('div', { class: 'row gap6 wrap post-actions' },
@@ -12116,6 +12130,84 @@ export function messagesFeature(ctx) {
}, x.url ? emojiImg(x.code, x.url) : x.e, x.url ? h('span', { class: 'small' }, x.code) : null)));
}
+ // ---- @mentions in posts and replies -----------------------------------------
+ // "@" and a few letters at the caret open a list of people under the box:
+ // the people you follow whose name matches first (instantly, from what's
+ // here), then the same search the header uses. Picking one puts a
+ // nostr:npub… in the text (NIP-27; every client shows it as @name) and the
+ // post goes out with their p tag, so they're told. Tab or Enter takes the
+ // first; Escape closes.
+ const MENTION_RE = /(?:^|\s)@([\p{L}\p{N}._-]{0,30})$/u;
+ let mentionRows = [];
+ const mentionSearcher = makeSearcher((q, rows) => {
+ if (!ui.mentionAc || ui.mentionAc.q !== q) return;
+ mentionRows = rows || [];
+ render();
+ });
+ function updateMentionAc(el, sel) {
+ const upto = el.value.slice(0, el.selectionEnd ?? el.value.length);
+ const m = MENTION_RE.exec(upto);
+ const next = m ? { sel, q: m[1], start: upto.length - m[1].length - 1, end: upto.length } : null;
+ const was = ui.mentionAc;
+ ui.mentionAc = next;
+ if (next && (!was || was.q !== next.q)) {
+ mentionRows = (was && next.q.startsWith(was.q)) ? mentionRows : [];
+ if (next.q.length >= 2) mentionSearcher.update(next.q); else mentionSearcher.clear(false);
+ }
+ if (!next) mentionSearcher.clear(false);
+ if (!!was !== !!next || (was && next && (was.q !== next.q || was.sel !== next.sel))) render();
+ }
+ function mentionHits(q) {
+ const ql = q.toLowerCase();
+ const mine = [...followsNow().set]
+ .filter((pk) => !hiddenPk(pk))
+ .map((pk) => ({ pk, name: displayName(pk) }))
+ .filter((r) => !ql || r.name.toLowerCase().includes(ql))
+ .sort((a, b) => Number(b.name.toLowerCase().startsWith(ql)) - Number(a.name.toLowerCase().startsWith(ql)))
+ .slice(0, ql ? 4 : 6)
+ .map((r) => { const p = profiles.get(r.pk) || {}; return { pk: r.pk, name: r.name, picture: p.picture || null, known: true }; });
+ const seen = new Set(mine.map((r) => r.pk));
+ return [...mine, ...mentionRows.filter((r) => !seen.has(r.pk) && !hiddenPk(r.pk))].slice(0, 8);
+ }
+ function pickMention(r) {
+ const ac = ui.mentionAc;
+ ui.mentionAc = null;
+ mentionSearcher.clear(false);
+ const el = ac && document.querySelector(ac.sel);
+ if (!el) { render(); return; }
+ el.focus();
+ el.setSelectionRange(ac.start, ac.end);
+ // as the editor would type it: undo history intact, and the box's own
+ // input handler stores the draft
+ const ins = 'nostr:' + npubOf(r.pk) + ' ';
+ if (typeof el._insert === 'function') el._insert(ins);
+ else { const v = el.value; el.value = v.slice(0, ac.start) + ins + v.slice(ac.end); el.dispatchEvent(new Event('input', { bubbles: true })); }
+ render();
+ }
+ // Keys while the list is open: Tab / Enter take the first, Escape closes.
+ function mentionKey(e) {
+ const ac = ui.mentionAc;
+ if (!ac) return;
+ if (e.key === 'Escape') { ui.mentionAc = null; render(); e.preventDefault(); return; }
+ if (e.key !== 'Tab' && e.key !== 'Enter') return;
+ const first = mentionHits(ac.q)[0];
+ if (!first) return;
+ e.preventDefault();
+ e.stopPropagation();
+ pickMention(first);
+ }
+ // Always a node, empty when idle (the morph must not rebuild the box
+ // under a typing finger when the list appears).
+ function mentionList(sel) {
+ const ac = ui.mentionAc;
+ const hits = ac && ac.sel === sel ? mentionHits(ac.q) : [];
+ return h('div', { class: 'mention-ac', onMousedown: (e) => e.preventDefault() }, // the box keeps focus and caret
+ ...(hits.length ? resultRows(h, hits, pickMention, (pk, ava) => ava) : []));
+ }
+ // The people a text mentions (nostr:npub / nprofile), for its p tags.
+ const mentionedPks = (text) => [...new Set([...String(text || '').matchAll(/nostr:((?:npub|nprofile)1[a-z0-9]+)/gi)]
+ .map((m) => parseNostrRef(m[1].toLowerCase())).filter((r) => r && r.type === 'pubkey').map((r) => r.pk))];
+
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' },
diff --git a/src/rich-text.js b/src/rich-text.js
index 7003f5c..b766767 100644
--- a/src/rich-text.js
+++ b/src/rich-text.js
@@ -46,20 +46,34 @@ function offsetOf(root, node, offset) {
return t.length;
}
-// (node, offset) for a text position; the field is normalised to text nodes
-// and <br>s, so that is all this walks.
+// (node, offset) for a text position, counted the way textOf counts: text,
+// <br>s, and the line break a new block (<div>, <p>) stands for — Chrome
+// wraps lines in <div>s once Enter has been pressed, and a walk of the top
+// level alone put every later position at the end of the field.
function pointAt(root, pos) {
- let left = pos;
- for (const c of root.childNodes) {
- if (c.nodeType === 3) {
- if (left <= c.data.length) return [c, left];
- left -= c.data.length;
- } else if (c.nodeName === 'BR') {
- if (left === 0) return [root, [...root.childNodes].indexOf(c)];
- left -= 1;
+ let left = pos, out = '';
+ const walk = (n) => {
+ const kids = [...n.childNodes];
+ for (let i = 0; i < kids.length; i++) {
+ const c = kids[i];
+ if (c.nodeType === 3) {
+ if (left <= c.data.length) return [c, left];
+ left -= c.data.length; out += c.data;
+ } else if (c.nodeName === 'BR') {
+ if (left === 0) return [n, i];
+ left -= 1; out += '\n';
+ } else if (c.nodeType === 1) {
+ if (BLOCK.has(c.nodeName) && out && !out.endsWith('\n')) {
+ if (left === 0) return [n, i];
+ left -= 1; out += '\n';
+ }
+ const got = walk(c);
+ if (got) return got;
+ }
}
- }
- return [root, root.childNodes.length];
+ return null;
+ };
+ return walk(root) || [root, root.childNodes.length];
}
const mediaOf = (dt) => [...((dt && dt.files) || [])].filter((f) => /^(image|video)\//.test(f.type));
diff --git a/src/style.css b/src/style.css
index 4df04d7..5e3ddd7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1624,3 +1624,7 @@ body::after {
.note-translate-btn:hover { color: var(--ink); }
/* a zap receipt shown as a row: what was sent, in the zap colour */
.note-zap-line { color: #e6a800; font-weight: 650; }
+/* @mention suggestions under a composer */
+.mention-ac { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); overflow: hidden; max-height: 280px; overflow-y: auto; }
+.mention-ac:empty { display: none; }
+.mention-ac .item { cursor: pointer; padding: 6px 10px; }
diff --git a/tools/mention-ac-test.js b/tools/mention-ac-test.js
new file mode 100644
index 0000000..52ba1ef
--- /dev/null
+++ b/tools/mention-ac-test.js
@@ -0,0 +1,117 @@
+// @mentions: "@" and a few letters in the post composer open a list of
+// people — the ones you follow first, then the search — and picking one puts
+// nostr:npub… in the text; the post goes out with their p tag.
+// Run: bun tools/mention-ac-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const FRIEND = getPublicKey(generateSecretKey()), STRANGER = getPublicKey(generateSecretKey());
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((stranger) => {
+ window.__published = [];
+ // the registrar's people search knows one stranger
+ const realFetch = window.fetch;
+ window.fetch = (url, opts) => {
+ if (/\/search\?q=/.test(String(url))) {
+ const q = new URL(String(url)).searchParams.get('q');
+ const results = 'satoshi nakamoto'.startsWith(q) || q.startsWith('sat') ? [{ pubkey: stranger, name: 'Satoshi Stranger', address: '[email protected]' }] : [];
+ return Promise.resolve(new Response(JSON.stringify({ results }), { headers: { 'content-type': 'application/json' } }));
+ }
+ return realFetch(url, opts);
+ };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; }
+ if (m[0] === 'EVENT') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, STRANGER);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 8000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const list = () => page.evaluate(() => [...document.querySelectorAll('.mention-ac .chat-name')].map((n) => n.textContent));
+const value = () => page.evaluate(() => document.querySelector('.post-input').value);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ await sleep(3000); // a new wallet's starter follows land first; ours go over them
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Globetrotter', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, FRIEND]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await sleep(1500);
+ const opened = await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((n) => (n.getAttribute('aria-label') || n.textContent).trim().toLowerCase() === 'new post'); if (b) { b.click(); return true; } return false; });
+ check('the composer opens', opened && !!(await page.waitForSelector('.post-input', { timeout: 5000 }).catch(() => null)));
+ await page.click('.post-input');
+
+ console.log('\n[someone you follow]');
+ await page.keyboard.type('hello @glo');
+ check('"@glo" lists the person you follow', await waitFor(() => [...document.querySelectorAll('.mention-ac .chat-name')].some((n) => n.textContent === 'Globetrotter')), JSON.stringify(await list()));
+ await page.keyboard.press('Enter');
+ await sleep(300);
+ check('Enter puts nostr:npub… in its place, list gone', (await value()) === 'hello nostr:' + npubEncode(FRIEND) + ' ' && !(await list()).length, JSON.stringify(await value()));
+
+ console.log('\n[someone found by the search]');
+ await page.keyboard.type('and @sato');
+ check('"@sato" lists the search\'s hit', await waitFor(() => [...document.querySelectorAll('.mention-ac .chat-name')].some((n) => n.textContent === 'Satoshi Stranger')), JSON.stringify(await list()));
+ await page.evaluate(() => [...document.querySelectorAll('.mention-ac .item')].find((n) => /Satoshi Stranger/.test(n.textContent)).click());
+ await sleep(300);
+ check('a tap puts them in too', (await value()).includes('and nostr:' + npubEncode(STRANGER) + ' '), JSON.stringify(await value()));
+ await page.keyboard.type('@nobody');
+ await sleep(300);
+ await page.keyboard.press('Escape');
+ await sleep(200);
+ check('Escape closes the list', !(await list()).length);
+ await page.keyboard.press('Backspace');
+ // on a line of its own (Chrome wraps lines in <div>s after an Enter)
+ await page.keyboard.type(' ');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('cc @glo');
+ await waitFor(() => [...document.querySelectorAll('.mention-ac .chat-name')].some((n) => n.textContent === 'Globetrotter'));
+ await page.keyboard.press('Enter');
+ await sleep(300);
+ check('...and on a second line, in place', /\ncc nostr:npub1[a-z0-9]+ $/.test(await value()) && !(await value()).includes('@glo'), JSON.stringify((await value()).slice(-90)));
+
+ console.log('\n[posting]');
+ await page.evaluate(() => { window.__published = []; [...document.querySelectorAll('.post-actions button')].find((b) => /^post$/i.test(b.textContent.trim())).click(); });
+ await waitFor(() => window.__published.some((e) => e.kind === 1), 8000);
+ const ev = (await page.evaluate(() => window.__published)).find((e) => e.kind === 1);
+ check('the post carries both p tags', !!ev && [FRIEND, STRANGER].every((pk) => ev.tags.some((x) => x[0] === 'p' && x[1] === pk)), JSON.stringify(ev && ev.tags));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ @mentions suggest people and tag them' : '\n❌ failed');
+process.exit(ok ? 0 : 1);