diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 09:42:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 09:42:16 -0700 |
| commit | 09834ebe3d22d1ed877a1e3620747d59c9c14aef (patch) | |
| tree | 5c689a4727e9341bf759cc3da26d625e43dfb110 /src | |
| parent | 4d0fc6bf4fd0494df1405899f51f9abbf5ab34a1 (diff) | |
| download | coinosv3-09834ebe3d22d1ed877a1e3620747d59c9c14aef.tar.gz coinosv3-09834ebe3d22d1ed877a1e3620747d59c9c14aef.zip | |
Posts: edit and delete your own. The ⋯ menu on your post gains Edit (a kind 1010 edit beside the original — the draft NIP-41 Amethyst reads and writes; the feed, thread and quote cards show the newest text with "· edited") and Delete (a NIP-09 kind 5, after a confirmation). Edits and deletions are asked for in the same REQ as each post's tallies and its live subscription, and count only when signed by the post's own author; they're remembered between sessions (postEdits cache) so a deleted post never flashes back from a cached feed. A post signed by the wallet key before a login was linked is still signed by that key. A deleted thread root keeps its place as "deleted by its author". relay.coinos.io now accepts kind 1010. tools/post-edit-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'src')
| -rw-r--r-- | src/cache-store.js | 2 | ||||
| -rw-r--r-- | src/features/messages.js | 161 | ||||
| -rw-r--r-- | src/i18n.js | 11 |
3 files changed, 166 insertions, 8 deletions
diff --git a/src/cache-store.js b/src/cache-store.js index dcfbad7..1257a04 100644 --- a/src/cache-store.js +++ b/src/cache-store.js @@ -30,7 +30,7 @@ const OPEN_WAIT_MS = 3000; // What lives here: rebuildable caches only. State a user would miss (read // markers, drafts, the follow list, sync outboxes) stays in localStorage, // where every tab sees every other tab's writes at once. -const WALLET_CACHES = ['profiles', 'profilesFaceless', 'reports', 'followPacks', 'profPages', 'relayLists']; +const WALLET_CACHES = ['profiles', 'profilesFaceless', 'reports', 'followPacks', 'profPages', 'relayLists', 'postEdits']; const WALLET_RE = new RegExp('^btc-wallet-cache:[0-9a-f]+:(' + WALLET_CACHES.join('|') + ')$'); const GLOBAL_KEYS = new Set([ // (btc-wallet-note-threads stays: a server-rendered note page seeds it diff --git a/src/features/messages.js b/src/features/messages.js index 5702198..ac25b54 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -3934,10 +3934,105 @@ 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; } } + // ---- edits and deletions of posts ---------------------------------------- + // A deletion is NIP-09 (kind 5 naming the post); an edit is kind 1010 — the + // draft NIP-41 Amethyst reads and writes: the new text, e-tagging the post + // it replaces. Either counts only when the post's own author signed it (the + // pair is filed by id AND pubkey, since the post may not be here yet when + // its edit arrives). Remembered between sessions so a deleted post never + // flashes back in from a cached feed before the relays have answered. + const EDIT = 1010; + const EDIT_KINDS = [5, EDIT]; + const POST_EDITS = 'postEdits'; + let postEditsCache = null; + function postEdits() { + if (!postEditsCache) { + let s = null; + try { s = wallet.loadFeatureState(POST_EDITS, null); } catch {} + postEditsCache = { del: new Set((s && s.del) || []), ed: new Map(Object.entries((s && s.ed) || {})) }; + } + return postEditsCache; + } + let postEditsTimer = null; + function savePostEdits() { + if (postEditsTimer) return; + postEditsTimer = setTimeout(() => { + postEditsTimer = null; + const p = postEdits(); + try { wallet.saveFeatureState(POST_EDITS, { del: [...p.del].slice(-2000), ed: Object.fromEntries([...p.ed].slice(-1000)) }); } catch {} + }, 500); + } + const editKey = (id, pk) => id + ':' + pk; + const isDeleted = (ev) => !!(ev && ev.id && ev.pubkey && postEdits().del.has(editKey(ev.id, ev.pubkey))); + // the newest edit by the post's author: { at, c, tags } or undefined + const editOf = (ev) => (ev && ev.id && ev.pubkey && (ev.kind === 1 || ev.kind === 1111) + ? postEdits().ed.get(editKey(ev.id, ev.pubkey)) : undefined); + // what a post says now, and the emoji its words may use + const noteText = (ev) => { const e = editOf(ev); return e ? e.c : ev.content; }; + const noteEmojiTags = (ev) => { const e = editOf(ev); return emojiTagMap(e ? [...ev.tags, ...e.tags] : ev.tags); }; + function notePostEdit(ev) { + const ids = (ev.tags || []).filter((x) => x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]); + if (!ids.length) return; + const p = postEdits(); + let changed = false; + if (ev.kind === 5) { + for (const id of ids) { + const k = editKey(id, ev.pubkey); + if (!p.del.has(k)) { p.del.add(k); changed = true; } + } + } else if (ev.kind === EDIT) { + const k = editKey(ids[0], ev.pubkey); + const cur = p.ed.get(k); + if (!cur || cur.at < ev.created_at) { + p.ed.delete(k); // re-inserted last: the newest are the ones kept + p.ed.set(k, { at: ev.created_at, c: String(ev.content || ''), tags: (ev.tags || []).filter((x) => x[0] === 'emoji' || x[0] === 'imeta') }); + changed = true; + } + } + if (changed) { savePostEdits(); scheduleRepaint(); } + } + // Sign as the post's author: the login identity, or the wallet's own key + // for what it posted before a nostr login was linked. + async function signAs(pk, partial) { + const id = await requireIdentity(); + if (id.pubkey === pk) return id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial); + if (wallet.nostr && wallet.nostr.sk && wallet.nostr.pk === pk) return finalizeEvent(partial, wallet.nostr.sk); + throw new Error(t('postNotYours')); + } + const editRelays = async (ev) => [...new Set([ + ...(await noteRelaysFor(ev).catch(() => [])), ...(await notesRelays(ev.pubkey).catch(() => [])), ...wallet.nostrRelays(), + ])]; + async function deletePost(ev) { + const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), + tags: [['e', ev.id], ['k', String(ev.kind)]] }; + const del = await signAs(ev.pubkey, partial); + notePostEdit(del); // gone from every list at once + render(); + const ok = await publishOn(await editRelays(ev), del); + if (!ok) throw new Error(t('msgSendFailed')); + } + async function editPost(ev, text) { + const emojis = [...emojiTagMap(ev.tags)].filter(([code]) => text.includes(':' + code + ':')).map(([code, url]) => ['emoji', code, url]); + const partial = { kind: EDIT, content: text, created_at: Math.max(Math.floor(Date.now() / 1000), (editOf(ev)?.at || 0) + 1), + tags: [['e', ev.id], ['p', ev.pubkey], ...emojis, ['alt', 'Edit of a post: ' + text.slice(0, 200)], CLIENT_TAG] }; + const evt = await signAs(ev.pubkey, partial); + const prev = editOf(ev); + notePostEdit(evt); + render(); + const ok = await publishOn(await editRelays(ev), evt); + if (!ok) { + const p = postEdits(), k = editKey(ev.id, ev.pubkey); + if (prev) p.ed.set(k, prev); else p.ed.delete(k); + savePostEdits(); render(); + throw new Error(t('msgSendFailed')); + } + } + // 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 === 1 || ev.kind === 1111) { noteReply(ev); return; } + if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; } if (ev.kind === 7 || ev.kind === 6) { if (seenNoteEv.has(ev.id)) return; seenNoteEv.add(ev.id); @@ -4029,8 +4124,8 @@ export function messagesFeature(ctx) { const relays = zapRelays(); for (let i = 0; i < batch.length; i += 150) { const slice = batch.slice(i, i + 150); - // replies and tallies in one REQ (two filters), not one each - queryManyOn(relays, [{ kinds: REPLY_KINDS, '#e': slice }, { kinds: NOTE_KINDS, '#e': slice }], 4000) + // replies, tallies and the author's edits in one REQ, not one each + queryManyOn(relays, [{ kinds: REPLY_KINDS, '#e': slice }, { kinds: NOTE_KINDS, '#e': slice }, { kinds: EDIT_KINDS, '#e': slice }], 4000) .then((all) => { for (const e of all) if (REPLY_KINDS.includes(e.kind)) noteReply(e); else noteEvent(e); // the relays have spoken for these ids: their live tally is the @@ -4052,7 +4147,7 @@ export function messagesFeature(ctx) { zapLiveTimer = null; zapLiveAt = Date.now(); if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} } - zapLiveUnsub = subscribeOn(zapRelays(), { kinds: [...NOTE_KINDS, ...REPLY_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent); + zapLiveUnsub = subscribeOn(zapRelays(), { kinds: [...NOTE_KINDS, ...REPLY_KINDS, ...EDIT_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent); }, Math.max(0, zapLiveAt + ZAP_LIVE_MS - Date.now())); }, Math.max(250, zapLastFlush + ZAP_FLUSH_MS - Date.now())); } @@ -7257,7 +7352,7 @@ export function messagesFeature(ctx) { h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, // one level deep only: a quote of a quote of a quote is a rabbit // hole, and the inner one stays a link you can follow - ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))), + ...noteBody(noteText(ev), depth + 1, noteEmojiTags(ev))), ev.kind === POLL ? pollBox(ev, { interactive: false }) : null); } @@ -8271,6 +8366,7 @@ export function messagesFeature(ctx) { && ((ev.tags || []).filter((x) => x[0] === 't').length > HASHTAG_MAX || (String(ev.content || '').match(/(^|\s)#[\p{L}\p{N}_]{2,}/gu) || []).length > HASHTAG_MAX); function hiddenWhy(ev) { + if (isDeleted(ev)) return 'deleted'; // by its author, yours included if (!ev || !ev.pubkey || isMe(ev.pubkey)) return null; const bm = baselineMutes(); for (const m of bm ? [mutesNow(), bm] : [mutesNow()]) { @@ -8805,6 +8901,50 @@ export function messagesFeature(ctx) { } const noteLink = (ev) => location.origin + '/' + (neventOf(ev.id, ev.pubkey) || ev.id); + // your own notes and comments; an article or a poll is edited elsewhere + const canEdit = (ev) => isMe(ev.pubkey) && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT); + // Editing one of your posts: its words, as they stand now, in a box. + function editSheet() { + const e = ui.postEdit; + if (!e) return null; + const close = () => { ui.postEdit = null; render(); }; + const save = async () => { + const text = String(e.text || '').trim(); + if (!text || text === noteText(e.ev).trim()) { close(); return; } + ui.postEdit = null; + try { await editPost(e.ev, text); toast(t('postEditedToast')); } + catch (err) { if (!(err instanceof NoIdentity)) toast(err.message || String(err)); ui.postEdit = { ev: e.ev, text }; render(); } + }; + return h('div', { class: 'confirm-pop-backdrop', onClick: (x) => { if (x.target === x.currentTarget) close(); } }, + h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('postEditTitle')), + h('coinos-text', { + class: 'post-edit-input', style: 'font-family:var(--sans);min-height:120px;max-height:50vh', + value: e.text, + onInput: (x) => { e.text = x.target.value; }, + }), + h('div', { class: 'small faint' }, t('postEditHelp')), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', onClick: close }, t('cancel')), + h('button', { class: 'btn-primary grow', onClick: save }, t('save'))))); + } + function deleteAskSheet() { + const ev = ui.postDeleteAsk; + if (!ev) return null; + const close = () => { ui.postDeleteAsk = null; render(); }; + const go = async () => { + close(); + try { await deletePost(ev); toast(t('postDeletedToast')); } + catch (err) { if (!(err instanceof NoIdentity)) toast(err.message || String(err)); } + }; + return h('div', { class: 'confirm-pop-backdrop', onClick: (x) => { if (x.target === x.currentTarget) close(); } }, + h('div', { class: 'card col confirm-pop', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, t('postDeleteTitle')), + h('div', { class: 'small muted' }, t('postDeleteHelp')), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', onClick: close }, t('cancel')), + h('button', { class: 'btn-danger grow', onClick: go }, t('postDelete'))))); + } // The ellipsis sheet: the actions that don't earn a button of their own. function noteSheet() { if (!ui.noteSheet) return null; @@ -8836,6 +8976,8 @@ export function messagesFeature(ctx) { typeof navigator !== 'undefined' && navigator.share ? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); }) : null, + canEdit(ev) ? item('\u270e', t('postEdit'), () => { ui.postEdit = { ev, text: noteText(ev) }; render(); }) : null, + mine && !ev.pending ? item('\u{1F5D1}', t('postDelete'), () => { ui.postDeleteAsk = ev; render(); }, true) : null, mine || !myPubkeys().length ? null : item('\u2630', t('listAddTo'), () => { ui.listPick = ev.pubkey; render(); }), mine ? null : item('\u{1F6A9}', t('postReport'), () => { ui.reportPick = ev; render(); }), item('\u{1F515}', mutesNow().threads.has(rootIdOf(ev)) ? t('postUnmuteThread') : t('postMuteThread'), () => toggleMuteThread(rootIdOf(ev))), @@ -8995,6 +9137,10 @@ export function messagesFeature(ctx) { // flagged by people you follow: folded behind a line that says who const flagged = !pending && !(ui.revealed && ui.revealed.has(ev.id)) ? reportsOn(ev) : null; if (flagged) return foldedRow(ev, flagged); + // deleted by its author: a thread's root or a reply in view keeps its + // place (the conversation around it still reads), saying only that + if (isDeleted(ev)) return h('div', { class: 'note-post small faint', 'data-zap-post': ev.id, style: 'padding:10px 0' }, t('postDeletedNote')); + const edited = editOf(ev); const openable = open && !pending; if (openable) threadPrefetch.watch(ev); if (!pending) watchZaps([ev.id]); @@ -9026,7 +9172,8 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000))), + (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + + (edited ? ' · ' + t('postEditedTag') : ''))), pending ? null : h('button', { // the overflow stays up here; reply, boost, quote, react and zap // are a row of their own under the post @@ -9036,7 +9183,7 @@ export function messagesFeature(ctx) { }, '\u22ef')), foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) - : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(ev.content, 0, emojiTagMap(ev.tags)))), + : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(noteText(ev), 0, noteEmojiTags(ev)))), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), !pending && whoOpen(ev.id) ? whoPanel(ev) : null)); @@ -10216,7 +10363,7 @@ export function messagesFeature(ctx) { // The sheets a post (or a person) can open: the ⋯ menu, the reaction // picker, the list picker. Every screen that shows a post draws them — // the thread and profile pages once left the ⋯ tap doing nothing. - const noteOverlays = () => [noteSheet(), reactPicker(), listPickSheet(), reportSheet(), signinSheet(), feedMenuSheet()]; + const noteOverlays = () => [noteSheet(), editSheet(), deleteAskSheet(), reactPicker(), listPickSheet(), reportSheet(), signinSheet(), feedMenuSheet()]; // The feed's own menu, behind the ⋯ in its header. function feedMenuSheet() { if (!ui.feedMenu) return null; diff --git a/src/i18n.js b/src/i18n.js index e078677..7f3dfc0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -435,6 +435,17 @@ const STR = { postMuteThread: 'Mute this thread', postUnmuteThread: 'Unmute this thread', postReport: 'Report…', + postEdit: 'Edit', + postEditTitle: 'Edit post', + postEditHelp: 'The edit is published beside the original (kind 1010). Apps that read edits show the new text; others keep showing what you first wrote.', + postEditedTag: 'edited', + postEditedToast: 'Post edited', + postDelete: 'Delete', + postDeleteTitle: 'Delete this post?', + postDeleteHelp: 'A deletion request goes to the relays (NIP-09). Most honour it and apps hide the post, but anyone who already copied it may keep it.', + postDeletedToast: 'Post deleted', + postDeletedNote: 'This post was deleted by its author.', + postNotYours: 'This post was signed by a key that isn’t signed in here', reportTitle: 'Report this post', reportHelp: 'A report is public (NIP-56). People who follow you see this post folded; the post is hidden for you.', reportSpam: 'Spam', |
