From 1ca77a5999bc546f92ec7189acfba1dc8088a2a7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 12 Sep 2026 09:52:06 -0700 Subject: Every answer to a post lives in one row beneath it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply and zap have come down from the header to join boost, quote and the heart, spread across the post's full width so each is a comfortable target instead of five buttons huddled in a corner. Only the ellipsis stays up top. Proper icons at a readable size, all the same weight: a speech bubble for reply (a return arrow is what a keyboard does, not what a person does), the repeat arrows for boost, quote marks, a heart, a bolt. Counts sit beside the icon they count. And the heart asks which feeling: tapping it opens the same six emoji the chat sheet offers, and the one you pick is the one the post then shows — NIP-25 takes any content, so there was never a reason to assume a heart. Tapping your own reaction still takes it back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/features/messages.js | 110 +++++++++++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 36 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index a5c8573..e1f3ea3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2712,10 +2712,20 @@ export function messagesFeature(ctx) { // A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the // only way nostr has: a deletion request for the reaction we sent. - async function toggleLike(ev) { + // Reply: open (or re-aim) the thread and put the cursor in the box. + function replyToNote(ev) { + if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { + ui.noteThread.focusId = ev.id; + render(); + } else openNoteThread(ev); + if (ui.noteThread) ui.noteThread.refocus = true; + setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120); + } + + async function unreact(ev) { const id = await requireIdentity(); const relays = await noteRelaysFor(ev); - if (myReactOn(ev.id)) { + { // Taking a like back is the only withdrawal nostr has: a deletion // request naming the reaction. Relays that honour it drop it; the ones // that don't go on showing it to other people — worth knowing, not @@ -2731,8 +2741,14 @@ export function messagesFeature(ctx) { publishOn(relays, del).catch(() => {}); return; } + } + + // React with whichever emoji was picked (NIP-25 takes any content). + async function reactTo(ev, emoji) { + const id = await requireIdentity(); + const relays = await noteRelaysFor(ev); const partial = { - kind: 7, content: '❤️', created_at: Math.floor(Date.now() / 1000), + kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000), tags: [['e', ev.id], ['p', ev.pubkey], CLIENT_TAG], }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); @@ -2873,23 +2889,57 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } - // Under a post: a like with its count, a boost with its count. Amethyst - // puts these in a row of their own rather than crowding the header, and - // they read better there — the counts are part of the post, not chrome. - function noteActions(ev) { + // The things you can do to a post, in a row of their own across the bottom + // of it: reply, boost, quote, react, zap. Counts sit beside their own icon, + // because a count belongs to the thing it counts. Spread across the full + // width so each is a comfortable target rather than five buttons huddled in + // a corner. + const ICON = (d, fill) => '' + d + ''; + const I_REPLY = ICON(''); + const I_BOOST = ICON(''); + const I_QUOTE = ICON('', true); + const I_HEART = (on) => ICON('', on); + const I_ZAP = ICON('', true); + + function noteActions(pk, ev, { canZap }) { const mineReact = myReactOn(ev.id); const rm = reacts.get(ev.id); const likeN = rm ? [...rm.values()].reduce((n, who) => n + who.size, 0) : 0; const boostN = (boosts.get(ev.id) || new Set()).size; - const mineBoost = iBoosted(ev.id); - const btn = (cls, label, count, on, onClick) => h('button', { + const btn = (icon, label, count, on, onClick) => h('button', { class: 'note-act' + (on ? ' on' : ''), title: label, 'aria-label': label, onClick: (e) => { e.stopPropagation(); onClick(); }, - }, cls, count ? h('span', { class: 'note-act-n' }, String(count)) : null); - return h('div', { class: 'row note-acts', style: 'gap:14px' }, - btn('\u2764', t('postLike'), likeN, !!mineReact, () => toggleLike(ev).catch(() => {})), - btn('\u21bb', t('postBoost'), boostN, mineBoost, () => boostNote(ev).catch(() => {})), - btn('\u275d', t('postQuote'), 0, false, () => quoteNote(ev))); + }, + typeof icon === 'string' && icon.startsWith(' replyToNote(ev)), + btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})), + btn(I_QUOTE, t('postQuote'), 0, false, () => quoteNote(ev)), + // tap to choose how you feel about it; tap again to take it back + btn(mineReact || I_HEART(false), t('postLike'), likeN, !!mineReact, + () => { if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }), + canZap ? btn(I_ZAP, t('zapTitle'), 0, false, () => { zapNote(pk, ev); recheckZap(ev.id); }) : null); + } + + // The emoji row, same set the chat sheet offers. + function reactPicker() { + if (!ui.reactPick) return null; + const ev = ui.reactPick; + const close = () => { ui.reactPick = null; render(); }; + return h('div', { + class: 'confirm-pop-backdrop', + onClick: (e) => { if (e.target === e.currentTarget) close(); }, + }, + h('div', { class: 'card col msg-sheet' }, + h('div', { class: 'msg-sheet-emojis' }, + REACT_EMOJIS.map((e2) => h('button', { + onClick: () => { close(); reactTo(ev, e2).catch(() => {}); }, + }, e2))), + h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } function noteRow(pk, ev, name, { open = true, focus = false } = {}) { @@ -2925,28 +2975,15 @@ export function messagesFeature(ctx) { h('span', { class: 'small faint', style: 'white-space:nowrap' }, (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)), zapChip(ev.id, { onClick: canZap ? () => { zapNote(pk, ev); recheckZap(ev.id); } : null })), - pending ? null : h('div', { class: 'row', style: 'gap:6px;flex-shrink:0' }, - // Reply on every post: opens (or re-targets) its thread and puts - // the cursor in the reply box — no hunting for the row tap. - h('button', { - class: 'btn-sm', title: t('msgReply'), onClick: (e) => { - e.stopPropagation(); - if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) { - ui.noteThread.focusId = ev.id; - render(); - } else openNoteThread(ev); - if (ui.noteThread) ui.noteThread.refocus = true; // survives the box relocating when the thread loads - setTimeout(() => document.querySelector('.thread-reply-input')?.focus(), 120); - }, - }, '↩'), - canZap ? h('button', { class: 'btn-sm', title: t('zapTitle'), onClick: (e) => { e.stopPropagation(); zapNote(pk, ev); recheckZap(ev.id); } }, '⚡') : null, - // everything else a post can have done to it, one tap away - h('button', { - class: 'btn-sm', title: t('postMore'), 'aria-label': t('postMore'), - onClick: (e) => { e.stopPropagation(); ui.noteSheet = ev; render(); }, - }, '\u22ef'))), + pending ? null : h('button', { + // the overflow stays up here; reply, boost, quote, react and zap + // are a row of their own under the post + class: 'btn-sm', title: t('postMore'), 'aria-label': t('postMore'), + style: 'flex-shrink:0', + onClick: (e) => { e.stopPropagation(); ui.noteSheet = ev; render(); }, + }, '\u22ef')), h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)), - pending ? null : noteActions(ev))); + pending ? null : noteActions(pk, ev, { canZap }))); } // ---- thread view: a note in its conversation ---------------------------- @@ -3654,7 +3691,8 @@ export function messagesFeature(ctx) { c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) : null, - noteSheet()); + noteSheet(), + reactPicker()); } -- cgit v1.2.3