diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 110 |
1 files changed, 74 insertions, 36 deletions
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) => '<svg width="19" height="19" viewBox="0 0 24 24" fill="' + (fill ? 'currentColor' : 'none') + + '" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:block">' + d + '</svg>'; + const I_REPLY = ICON('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>'); + const I_BOOST = ICON('<path d="M17 2.5l3.5 3.5-3.5 3.5"/><path d="M3.5 11.5v-1a4 4 0 0 1 4-4h13"/><path d="M7 21.5L3.5 18 7 14.5"/><path d="M20.5 12.5v1a4 4 0 0 1-4 4h-13"/>'); + const I_QUOTE = ICON('<path stroke="none" d="M7.2 17c.5 0 1-.3 1.2-.7l1.4-2.9c.1-.3.2-.6.2-.9V8c0-.6-.4-1-1-1H6c-.6 0-1 .4-1 1v4c0 .6.4 1 1 1h2l-1 2.1c-.5.9.2 1.9 1.2 1.9zm10 0c.5 0 1-.3 1.2-.7l1.4-2.9c.1-.3.2-.6.2-.9V8c0-.6-.4-1-1-1H16c-.6 0-1 .4-1 1v4c0 .6.4 1 1 1h2l-1 2.1c-.5.9.2 1.9 1.2 1.9z"/>', true); + const I_HEART = (on) => ICON('<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 1 0-7.8 7.8l1.1 1L12 21l7.7-7.7 1.1-1a5.5 5.5 0 0 0 0-7.7z"/>', on); + const I_ZAP = ICON('<path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z"/>', 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('<svg') + ? h('span', { style: 'display:flex', html: icon }) + : h('span', { class: 'note-act-emoji' }, icon), + count ? h('span', { class: 'note-act-n' }, String(count)) : null); + return h('div', { class: 'row note-acts' }, + btn(I_REPLY, t('msgReply'), 0, false, () => 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()); } |
