diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 98 | ||||
| -rw-r--r-- | src/rich-text.js | 38 | ||||
| -rw-r--r-- | src/style.css | 4 |
3 files changed, 125 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; } |
