diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 16:13:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 16:13:58 -0700 |
| commit | 52612bc8bc4c9ea18b0520fc23c98094f304cc05 (patch) | |
| tree | fc4241766ccb9ab6c9676baa34613df22796f44c | |
| parent | 3dbd82cafb0fbada4270525bec607ba32645d26b (diff) | |
| download | coinosv3-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.js | 98 | ||||
| -rw-r--r-- | src/rich-text.js | 38 | ||||
| -rw-r--r-- | src/style.css | 4 | ||||
| -rw-r--r-- | tools/mention-ac-test.js | 117 |
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); |
