aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 09:42:16 -0700
committerAdam Soltys <[email protected]>2026-10-09 09:42:16 -0700
commit09834ebe3d22d1ed877a1e3620747d59c9c14aef (patch)
tree5c689a4727e9341bf759cc3da26d625e43dfb110 /src
parent4d0fc6bf4fd0494df1405899f51f9abbf5ab34a1 (diff)
downloadcoinosv3-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.js2
-rw-r--r--src/features/messages.js161
-rw-r--r--src/i18n.js11
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',