aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 08:28:02 -0700
committerAdam Soltys <[email protected]>2026-09-12 08:28:02 -0700
commitf34b5a155e7cb63e15eff7e7ba9559292b31c5e1 (patch)
treed5fdb28f3f2212f24067db73fd259d76a6f8706a
parentd4576229565d9b4411d0d9fc44780da03ef0b47b (diff)
downloadcoinosv3-f34b5a155e7cb63e15eff7e7ba9559292b31c5e1.tar.gz
coinosv3-f34b5a155e7cb63e15eff7e7ba9559292b31c5e1.zip
Answer a post: like it, boost it, quote it, or stop seeing its author.
Under every post now: a heart with its count, a boost with its count, and a quote. Behind an ellipsis beside reply and zap: quote, boost, copy the note's reference, mute the author, or block them. Likes and boosts come back in the SAME relay request the zap tallies already make — one round trip answers all three — and each action paints before any relay does, the way the zap chip learned to. A like is kind 7 and a boost kind 6, published to the author's own write relays as well as ours, so the person being answered actually sees it. A quote opens the composer with a nostr:nevent reference already in the text and tags the post on publish, so other clients render the embed. Taking a like back is the only withdrawal nostr has — a deletion request naming the reaction. Mute is the NIP-51 list (kind 10000): their posts leave the feed at once and the list is published, so it follows you to other clients. Block is the same list plus an unfollow, which is why it says so. And a bug the mute found, which the follow list had too: the optimistic paint was being read back as fact. Both toggles paint the answer immediately and then publish onto the newest list the relays will give them — but when the relays had nothing newer, "the newest list" WAS the optimistic paint, so the code saw the follow already present and published a list without it. Every follow was a no-op that looked like it worked. The base is now the relays' copy only when it's genuinely newer, and otherwise what we held before the paint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/features/messages.js284
-rw-r--r--src/i18n.js12
-rw-r--r--src/nostr.js5
-rw-r--r--src/style.css11
-rw-r--r--tools/post-actions-test.js119
5 files changed, 417 insertions, 14 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e95bfeb..a5c8573 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -14,7 +14,7 @@
import {
subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays,
- npubOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
+ npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread,
} from '../nostr.js';
import {
@@ -1962,6 +1962,13 @@ export function messagesFeature(ctx) {
// rumor with an id like any event, so a zap aimed at it e-tags that id —
// the receipt reveals nothing but the id itself.
const ZAP_KINDS = [9735, 9737];
+ // ...and, in the same REQ, what else happened to a note: likes (kind 7)
+ // and boosts (kind 6). One round trip for all three.
+ const NOTE_KINDS = [9735, 9737, 7, 6];
+ const reacts = new Map(); // note id -> Map(emoji -> Set<pubkey>)
+ const boosts = new Map(); // note id -> Set<pubkey>
+ const seenNoteEv = new Set(); // event ids already counted
+ const myReactEv = new Map(); // note id -> the id of OUR reaction, so it can be withdrawn
const zapTotals = new Map(); // id -> { sats, seen: Set<receipt id>, mine }
const zapAsked = new Set();
let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = [];
@@ -1986,6 +1993,35 @@ export function messagesFeature(ctx) {
if (tagOf(ev, 'P')) return tagOf(ev, 'P');
try { return (JSON.parse(tagOf(ev, 'description') || 'null') || {}).pubkey || null; } catch { return null; }
}
+ // Where an event about a note goes. A zap receipt is money and has its own
+ // bookkeeping (see below); a like and a boost are just tallies of who.
+ function noteEvent(ev) {
+ if (ev.kind === 7 || ev.kind === 6) {
+ if (seenNoteEv.has(ev.id)) return;
+ seenNoteEv.add(ev.id);
+ // the LAST e tag is the note being reacted to (NIP-25)
+ const ids = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]).map((x) => x[1]);
+ const id = ids[ids.length - 1];
+ if (!id) return;
+ if (ev.kind === 6) {
+ if (!boosts.has(id)) boosts.set(id, new Set());
+ boosts.get(id).add(ev.pubkey);
+ } else {
+ // '+' and an empty content both mean a plain like
+ const emoji = !ev.content || ev.content === '+' ? '\u2764\ufe0f' : ev.content.slice(0, 12);
+ if (!reacts.has(id)) reacts.set(id, new Map());
+ const m = reacts.get(id);
+ if (!m.has(emoji)) m.set(emoji, new Set());
+ m.get(emoji).add(ev.pubkey);
+ // ours, wherever it was sent from — so it can be taken back here
+ if (myPubkeys().includes(ev.pubkey)) myReactEv.set(id, ev.id);
+ }
+ scheduleRepaint();
+ return;
+ }
+ noteReceipt(ev);
+ }
+
function noteReceipt(ev) {
const sats = receiptSats(ev);
if (!sats) return;
@@ -2018,9 +2054,9 @@ export function messagesFeature(ctx) {
const relays = zapRelays();
for (let i = 0; i < batch.length; i += 150) {
const slice = batch.slice(i, i + 150);
- queryOn(relays, { kinds: ZAP_KINDS, '#e': slice }, 4000)
+ queryOn(relays, { kinds: NOTE_KINDS, '#e': slice }, 4000)
.then((evs) => {
- evs.forEach(noteReceipt);
+ evs.forEach(noteEvent);
// the relays have spoken for these ids: their live tally is the
// truth now, remembered or not (a zap that was deleted has to be
// able to disappear)
@@ -2031,7 +2067,7 @@ export function messagesFeature(ctx) {
}
zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200);
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
- zapLiveUnsub = subscribeOn(relays, { kinds: ZAP_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteReceipt);
+ zapLiveUnsub = subscribeOn(relays, { kinds: NOTE_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent);
}, 250);
}
// Tallies are asked of the relays again on every boot, and that takes
@@ -2319,7 +2355,13 @@ export function messagesFeature(ctx) {
saveFollows({ ...before, set: new Set(before.set.has(pk) ? [...before.set].filter((x) => x !== pk) : [...before.set, pk]) });
render();
try {
- const base = await syncFollows({ force: true });
+ await syncFollows({ force: true });
+ // The list to publish is the relays' copy if it's genuinely newer than
+ // what we held, and otherwise what we held BEFORE the optimistic paint
+ // — reading our own paint back as fact made a follow publish a list
+ // without it, and a mute publish a list without the mute.
+ const fetched = followsNow();
+ const base = fetched.at > before.at ? fetched : before;
const had = base.set.has(pk);
const tags = had
? base.tags.filter((x) => !(x[0] === 'p' && x[1] === pk))
@@ -2481,7 +2523,7 @@ export function messagesFeature(ctx) {
function mergeFeed(evs) {
const c = feedNow();
const seen = new Set(c.notes.map((e) => e.id));
- const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !seen.has(e.id) && seen.add(e.id));
+ const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !seen.has(e.id) && seen.add(e.id));
if (!add.length) return false;
c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
// posts arriving at the TOP shouldn't cost you the ones you'd scrolled to
@@ -2650,6 +2692,206 @@ export function messagesFeature(ctx) {
// One post as a feed row (avatar · name · time · body), twitter/jumble
// style: rows share a scrollable container and are split by hairlines
// rather than floating in their own cards. Tapping a row opens its thread.
+ // ---- what you can do to a post -------------------------------------------
+ // Like, boost, quote, and — behind the ellipsis — mute or block its author.
+ // Each publishes to the author's own relays as well as ours, so the person
+ // being answered actually sees it.
+ const myReactOn = (id) => {
+ const m = reacts.get(id);
+ if (!m) return null;
+ const mine = myPubkeys();
+ for (const [emoji, who] of m) for (const pk of mine) if (who.has(pk)) return emoji;
+ return null;
+ };
+ const iBoosted = (id) => {
+ const set = boosts.get(id);
+ return !!set && myPubkeys().some((pk) => set.has(pk));
+ };
+ const noteRelaysFor = async (ev) =>
+ [...new Set([...zapRelays(), ...(await relaysOf(ev.pubkey))])];
+
+ // 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) {
+ 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
+ // worth refusing to try.
+ const m = reacts.get(ev.id);
+ if (m) for (const [emoji, who] of [...m]) { for (const pk of myPubkeys()) who.delete(pk); if (!who.size) m.delete(emoji); }
+ render();
+ const mineEv = myReactEv.get(ev.id);
+ if (!mineEv) return;
+ myReactEv.delete(ev.id);
+ const gone = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', mineEv], ['k', '7']] };
+ const del = id.signer instanceof Uint8Array ? finalizeEvent(gone, id.signer) : await id.signer.signEvent(gone);
+ publishOn(relays, del).catch(() => {});
+ return;
+ }
+ const partial = {
+ kind: 7, content: '❤️', 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);
+ myReactEv.set(ev.id, evt.id);
+ noteEvent(evt); // on screen before the relays answer
+ render();
+ publishOn(relays, evt).catch(() => {});
+ }
+
+ // A boost is kind 6 carrying the note it repeats (NIP-18).
+ async function boostNote(ev) {
+ if (iBoosted(ev.id)) { toast(t('postBoostedAlready')); return; }
+ const id = await requireIdentity();
+ const relays = await noteRelaysFor(ev);
+ const partial = {
+ kind: 6, content: JSON.stringify(ev), created_at: Math.floor(Date.now() / 1000),
+ tags: [['e', ev.id, relays[0] || '', 'mention'], ['p', ev.pubkey], CLIENT_TAG],
+ };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ noteEvent(evt);
+ render();
+ const ok = await publishOn(relays, evt);
+ toast(ok ? t('postBoosted') : t('msgSendFailed'));
+ }
+
+ // A quote is your own post with theirs referenced inside it: the composer
+ // opens with the reference already in the text, and the q tag goes on at
+ // publish time so clients can render the embed.
+ function quoteNote(ev) {
+ ui.quoteOf = { id: ev.id, pubkey: ev.pubkey };
+ const ref = 'nostr:' + (neventOf(ev.id, ev.pubkey) || ev.id);
+ const cur = composeText().replace(/\s+$/, '');
+ const next = (cur ? cur + '\n\n' : '') + ref;
+ ui.profCompose = next;
+ setDraft(POST_DRAFT, next);
+ ui.noteThread = null;
+ ui.profilePk = null;
+ ui.chatOpen = true;
+ ui.msgView = 'feed';
+ feedNow();
+ render();
+ setTimeout(() => { const el = document.querySelector('.chat-page textarea'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
+ }
+
+ // ---- mute list (NIP-51 kind 10000) ---------------------------------------
+ // Muting hides someone's posts everywhere in this app and publishes the
+ // list, so it follows you to other clients. Blocking is the same list plus
+ // an unfollow — the stronger, more deliberate door.
+ const MUTES = 'mutes';
+ let mutes = null; // { set, tags, content, at }
+ let mutesAt = 0;
+ function mutesNow() {
+ if (!mutes) {
+ let st2 = null;
+ try { st2 = wallet.loadFeatureState(MUTES, null); } catch {}
+ const tags = (st2 && st2.tags) || [];
+ mutes = { set: new Set(pTags(tags).map((x) => x[1])), tags, content: (st2 && st2.c) || '', at: (st2 && st2.at) || 0 };
+ }
+ return mutes;
+ }
+ const isMuted = (pk) => mutesNow().set.has(pk);
+ function saveMutes(m) {
+ mutes = m;
+ try { wallet.saveFeatureState(MUTES, { tags: m.tags, c: m.content, at: m.at }); } catch {}
+ }
+ async function syncMutes({ force = false } = {}) {
+ const me = mePk();
+ if (!me) return mutesNow();
+ if (!force && Date.now() - mutesAt < 10 * 60_000) return mutesNow();
+ mutesAt = Date.now();
+ try {
+ const evs = await queryOn(zapRelays(), { kinds: [10000], authors: [me] }, 5000);
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const cur = mutesNow();
+ if (newest && newest.created_at > cur.at) {
+ saveMutes({ set: new Set(pTags(newest.tags).map((x) => x[1])), tags: newest.tags || [], content: newest.content || '', at: newest.created_at });
+ scheduleRepaint();
+ }
+ } catch { mutesAt = 0; }
+ return mutesNow();
+ }
+ // Same care as the follow list: publish onto the freshest copy the relays
+ // will give us, preserving anything in it we didn't write (the encrypted
+ // content other clients keep their private mutes in, above all).
+ async function toggleMute(pk, { block = false } = {}) {
+ const id = await requireIdentity();
+ if (pk === id.pubkey) return;
+ const before = mutesNow();
+ saveMutes({ ...before, set: new Set(before.set.has(pk) ? [...before.set].filter((x) => x !== pk) : [...before.set, pk]) });
+ render();
+ try {
+ await syncMutes({ force: true });
+ const fetched = mutesNow(); // see the note in toggleFollow
+ const base = fetched.at > before.at ? fetched : before;
+ const had = base.set.has(pk);
+ const tags = had ? base.tags.filter((x) => !(x[0] === 'p' && x[1] === pk)) : [...base.tags, ['p', pk]];
+ const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
+ const partial = { kind: 10000, content: base.content || '', created_at, tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const ok = await publishOn(zapRelays(), evt);
+ if (!ok) throw new Error(t('msgSendFailed'));
+ saveMutes({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
+ if (block && !had && isFollowing(pk)) await toggleFollow(pk);
+ toast(had ? t('postUnmuted') : block ? t('postBlocked') : t('postMuted'));
+ render();
+ } catch (e) {
+ saveMutes(before);
+ if (!(e instanceof NoIdentity)) toast(e.message || String(e));
+ render();
+ }
+ }
+
+ // The ellipsis sheet: the actions that don't earn a button of their own.
+ function noteSheet() {
+ if (!ui.noteSheet) return null;
+ const ev = ui.noteSheet;
+ const close = () => { ui.noteSheet = null; render(); };
+ const mine = isMe(ev.pubkey);
+ const item = (icon, label, onClick, danger) => h('button', {
+ class: 'btn-block', style: 'text-align:left' + (danger ? ';color:var(--red,#c0392b)' : ''),
+ onClick: () => { close(); onClick(); },
+ }, icon + ' ' + label);
+ return h('div', {
+ class: 'confirm-pop-backdrop',
+ onClick: (e) => { if (e.target === e.currentTarget) close(); },
+ },
+ h('div', { class: 'card col confirm-pop', style: 'gap:8px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ avatar(ev.pubkey, 'chat-avatar', false),
+ h('div', { class: 'chat-name' }, displayName(ev.pubkey))),
+ item('❝', t('postQuote'), () => quoteNote(ev)),
+ item('↻', t('postBoost'), () => boostNote(ev).catch(() => {})),
+ item('⧉', t('copy'), async () => {
+ try { await navigator.clipboard.writeText('nostr:' + (neventOf(ev.id, ev.pubkey) || ev.id)); toast(t('copied')); } catch {}
+ }),
+ mine ? null : item('\u{1F507}', isMuted(ev.pubkey) ? t('postUnmute') : t('postMute'), () => toggleMute(ev.pubkey).catch(() => {})),
+ mine || isMuted(ev.pubkey) ? null : item('⛔', t('postBlock'), () => toggleMute(ev.pubkey, { block: true }).catch(() => {}), true),
+ 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) {
+ 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', {
+ 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)));
+ }
+
function noteRow(pk, ev, name, { open = true, focus = false } = {}) {
prefetchProfilePage(pk); // rows are tap-targets: have the page warm
const isReply = ev.tags.some((x) => x[0] === 'e');
@@ -2697,8 +2939,14 @@ export function messagesFeature(ctx) {
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)),
- h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content))));
+ 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'))),
+ h('div', { class: 'small', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content)),
+ pending ? null : noteActions(ev)));
}
// ---- thread view: a note in its conversation ----------------------------
@@ -2787,11 +3035,16 @@ export function messagesFeature(ctx) {
// NIP-92: what we uploaded, described, so clients needn't sniff the URL
const imeta = media.filter((m) => m && m.url)
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, CLIENT_TAG] };
+ // NIP-18 quote: the reference is already in the text; the tags let a
+ // 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] };
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);
if (!ok) throw new Error(t('msgSendFailed'));
+ ui.quoteOf = null;
for (const c of caches) c.notes = c.notes.map((e) => (e.id === temp.id ? evt : e));
if (feed) feed.notes = feed.notes.map((e) => (e.id === temp.id ? evt : e));
render();
@@ -3374,7 +3627,8 @@ export function messagesFeature(ctx) {
function feedView() {
const c = feedNow();
const authors = feedAuthors();
- const rows = c.notes.slice(0, c.shown || FEED_PAGE).flatMap((ev, i) => [
+ const visible = c.notes.filter((ev) => !isMuted(ev.pubkey));
+ const rows = visible.slice(0, c.shown || FEED_PAGE).flatMap((ev, i) => [
i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null,
noteRow(ev.pubkey, ev, displayName(ev.pubkey)),
]);
@@ -3392,14 +3646,15 @@ export function messagesFeature(ctx) {
? h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoFollows')),
h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')))
- : c.status === 'loading' && !c.notes.length
+ : c.status === 'loading' && !visible.length
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
- : !c.notes.length
+ : !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' }))
- : null);
+ : null,
+ noteSheet());
}
@@ -4356,6 +4611,7 @@ export function messagesFeature(ctx) {
syncLists().catch(() => {});
syncInbox().catch(() => {});
syncFollows().catch(() => {}); // the Follow button should be right on first paint
+ syncMutes().catch(() => {}); // and a muted author shouldn't flash past before the list lands
registerPush().catch(() => {}); // silent refresh when permission already granted
},
stop() {
@@ -4373,6 +4629,8 @@ export function messagesFeature(ctx) {
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
stopFeedWatch();
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
+ mutes = null; mutesAt = 0;
+ reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear();
clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null;
zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next
},
diff --git a/src/i18n.js b/src/i18n.js
index a8f6dfd..4121df1 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -210,6 +210,18 @@ const STR = {
feedFindPeople: 'Find people',
feedEmpty: 'Nothing posted yet.',
feedAttach: 'Attach a picture or video',
+ postLike: 'Like',
+ postBoost: 'Boost',
+ postBoosted: 'Boosted',
+ postBoostedAlready: 'You already boosted that',
+ postQuote: 'Quote',
+ postMore: 'More',
+ postMute: 'Mute',
+ postUnmute: 'Unmute',
+ postMuted: 'Muted — their posts are hidden',
+ postUnmuted: 'Unmuted',
+ postBlock: 'Block and unfollow',
+ postBlocked: 'Blocked',
zapSending: 'Sending your zap…',
msgReply: 'Reply',
profNewPost: 'New post',
diff --git a/src/nostr.js b/src/nostr.js
index 50ed1f4..828fde3 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -13,7 +13,7 @@ import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
-import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
+import { decode as nip19decode, npubEncode, nsecEncode, neventEncode } from 'nostr-tools/nip19';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { SimplePool } from 'nostr-tools/pool';
import { randomBytes, bytesToHex } from '@noble/hashes/utils';
@@ -209,6 +209,9 @@ export function parseNostrPubkey(input) {
}
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } }
+// An event reference other clients can resolve: the id plus who wrote it, so
+// a quote can be fetched even by someone who doesn't have it yet.
+export function neventOf(id, author) { try { return neventEncode({ id, author }); } catch { return null; } }
// Any nostr entity reference → something renderable: npub/nprofile land as a
// pubkey (with relay hints when carried), note/nevent as an event id.
export function parseNostrRef(input) {
diff --git a/src/style.css b/src/style.css
index 4d71435..4eceb98 100644
--- a/src/style.css
+++ b/src/style.css
@@ -533,6 +533,17 @@ hr.divider { margin: 16px 0; }
/* Profile note feed: inline images stay contained, links wrap. */
.note-img { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-top: 6px; object-fit: contain; }
.note-img.clickable { cursor: zoom-in; }
+/* The row under a post: like, boost, quote. Quiet until they carry a count
+ or you've used one, the way a timeline should read. */
+.note-acts { margin-top: 6px; }
+.note-act {
+ background: none; border: 0; padding: 2px 0; margin: 0; font-size: 14px; line-height: 1;
+ color: var(--faint); display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
+}
+.note-act:hover { background: none; color: var(--ink); }
+.note-act.on { color: #e0245e; }
+.note-act.on[title="Boost"], .note-act.on[aria-label="Boost"] { color: var(--green); }
+.note-act-n { font-size: 12px; font-weight: 600; color: var(--muted); }
/* The attach button sits between Post and Cancel: a square the size of the
buttons beside it, not the sliver a .btn-sm made of a 20px icon. */
.attach-btn {
diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js
new file mode 100644
index 0000000..c1126ef
--- /dev/null
+++ b/tools/post-actions-test.js
@@ -0,0 +1,119 @@
+// What you can do to a post: like it, boost it, quote it, and — behind the
+// ellipsis — mute or block whoever wrote it. Muting hides their posts here
+// and goes onto the NIP-51 list that follows you to other clients.
+//
+// The like and boost tallies come back in the same REQ as the zaps; this
+// checks the rendering and the local half of each action (nothing here needs
+// a relay to answer, so the test doesn't depend on one).
+//
+// Run: bun tools/post-actions-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const png = await Bun.file('/tmp/claude-1000/-home-adam-coinosv3/61dea255-d26d-411d-be8b-92dfb0a75887/scratchpad/big.png').arrayBuffer().catch(() => null);
+const server = Bun.serve({ port: 5258, async fetch(req) {
+ const u = new URL(req.url);
+ if (u.pathname === '/pic.png' && png) return new Response(png, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const AUTHOR = 'a'.repeat(63) + '9';
+// a fresh id each run: '1'.repeat(64) is a real, much-reacted-to id on nostr
+// and the relays cheerfully return four likes for it
+const NOTE_ID = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, '0')).join('');
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5258/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('get started'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+
+ await page.evaluate(([k, pk, nid]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify([
+ { id: nid, pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'More flowers http://localhost:5258/pic.png', tags: [] },
+ ]));
+ }, [base, AUTHOR, NOTE_ID]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(1000);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
+ await sleep(2500);
+
+ const acts = await page.evaluate(() => [...document.querySelectorAll('.note-act')].map((b) => b.getAttribute('aria-label')));
+ check('a post carries like, boost and quote', acts.join(',') === 'Like,Boost,Quote', acts.join(',') || 'none');
+
+ // a like paints before any relay answers, and counts itself
+ const before = await page.evaluate(() => Number(document.querySelector('.note-act').textContent.replace(/\D/g, '') || 0));
+ await page.evaluate(() => document.querySelector('.note-act').click());
+ await sleep(900);
+ const liked = await page.evaluate(() => { const b = document.querySelector('.note-act'); return { on: b.className.includes('on'), n: Number(b.textContent.replace(/\D/g, '') || 0) }; });
+ check('liking shows immediately, with a count', liked.on && liked.n === before + 1, JSON.stringify(liked) + ' was ' + before);
+
+ // quoting carries the post into the composer as a nostr: reference
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Quote'); b.click(); });
+ await sleep(900);
+ const draft = await page.evaluate(() => (document.querySelector('.chat-page textarea') || {}).value || '');
+ check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty');
+
+ // the ellipsis sheet, and muting from it
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.btn-sm')].find((x) => x.textContent.trim() === '⋯'); if (b) b.click(); });
+ await sleep(600);
+ const sheet = await page.evaluate(() => { const s = document.querySelector('.confirm-pop'); return s ? s.innerText.replace(/\n+/g, ' | ') : ''; });
+ check('the ellipsis offers mute and block', /Mute/.test(sheet) && /Block/.test(sheet), sheet.slice(0, 70));
+
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.confirm-pop button')].find((x) => /^🔇/.test(x.textContent)); if (b) b.click(); });
+ await sleep(2500);
+ const after = await page.evaluate((k) => ({
+ rows: document.querySelectorAll('.notes-feed > .row').length,
+ muted: (JSON.parse(localStorage.getItem(k + ':mutes') || 'null') || {}).tags || [],
+ }), base);
+ check('a muted author leaves the feed at once', after.rows === 0, after.rows + ' posts still shown');
+ check('...and goes onto the published mute list', after.muted.some((tg) => tg[1] === AUTHOR), JSON.stringify(after.muted).slice(0, 50));
+ // An image in a DM opens the same way a post's does — both bubbles render
+ // their text through the same function, so this is a check that they keep
+ // doing so.
+ await page.evaluate(([k, peer]) => {
+ const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
+ st.dms = { [peer]: [{ id: '2'.repeat(64), from: peer, text: 'look http://localhost:5258/pic.png', t: Math.floor(Date.now() / 1000) }] };
+ localStorage.setItem(k + ':messages', JSON.stringify(st));
+ }, [base, AUTHOR]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(2500);
+ const opened = await page.evaluate(() => {
+ const row = [...document.querySelectorAll('.chat-thread-row')].find((r) => !/feed/i.test(r.textContent));
+ if (!row) return 'no dm row';
+ row.click();
+ return 'opened';
+ });
+ await sleep(1200);
+ const img = await page.evaluate(() => { const i = document.querySelector('.chat-bubble img.note-img'); if (!i) return null; i.click(); return true; });
+ await sleep(600);
+ check('an image in a DM opens full screen too', !!img && await page.evaluate(() => !!document.querySelector('.lightbox')), img ? 'tapped' : 'no image in the bubble (' + opened + ')');
+
+ check('no page errors', !errs.length, errs.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ posts answer back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);