aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js284
1 files changed, 271 insertions, 13 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
},