aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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 /src/features
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
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js98
1 files changed, 95 insertions, 3 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' },