aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 09:52:06 -0700
committerAdam Soltys <[email protected]>2026-09-12 09:52:06 -0700
commit1ca77a5999bc546f92ec7189acfba1dc8088a2a7 (patch)
tree091d851c4375c8b254ec1ec14cd9373ee8b4efbe /src/features
parentf34b5a155e7cb63e15eff7e7ba9559292b31c5e1 (diff)
downloadcoinosv3-1ca77a5999bc546f92ec7189acfba1dc8088a2a7.tar.gz
coinosv3-1ca77a5999bc546f92ec7189acfba1dc8088a2a7.zip
Every answer to a post lives in one row beneath it.
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) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js110
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());
}