[comments] honor NIP-09 deletions in comment threads

Deleted comments previously persisted (no kind-5 handling; the thread cache
only merged-in). The thread service now processes NIP-09 deletion requests:

- A kind-5 event removes a target comment only when it is signed by that
  comment's own author (you cannot delete someone else's comment). The author
  match is the security boundary.
- Deletions are remembered per target, so a comment that arrives after its
  author's deletion (out-of-order relays) is suppressed on insert.
- On load, after fetching the thread, deletions for the loaded comment ids are
  fetched (kind 5, #e: ids) and applied before the thread is rendered, so the
  cache can't resurrect a deleted comment. Removal re-renders the thread.
- Replies to a removed comment become unreachable from the root and are hidden
  with their deleted parent.

`fetchDeletionEvents` is injectable; the default queries the relay pool. Live
deletions mid-session aren't subscribed yet (a kind-5 can't be scoped by the
video upfront) — deletions are honored on each thread open.

Tests: author-signed deletion removes the comment; a non-author deletion is
ignored; an out-of-order deletion suppresses the later comment. Both the
author-match and the suppress-on-insert guard are mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-18 13:55:03 -04:00
1 parent 6f5da76d30
commit f7cea24068
2 files changed
+310

No files matched your search

+174
View File
@@ -46,6 +46,13 @@ function normalizeKind(value) {
return "";
}
// NIP-09 deletion request.
const DELETION_EVENT_KIND = 5;
function isDeletionEvent(event) {
return normalizeKind(event?.kind) === String(DELETION_EVENT_KIND);
}
function toParentKey(parentId) {
return parentId ? parentId : ROOT_PARENT_KEY;
}
@@ -105,6 +112,7 @@ export default class CommentThreadService {
app = null,
fetchVideoComments = null,
subscribeVideoComments = null,
fetchDeletionEvents = null,
getProfileCacheEntry = null,
batchFetchProfiles = null,
limit = DEFAULT_INITIAL_LIMIT,
@@ -114,6 +122,10 @@ export default class CommentThreadService {
this.nostrClient = nostrClient;
this.fetchVideoComments = fetchVideoComments;
this.subscribeVideoComments = subscribeVideoComments;
this.fetchDeletionEvents =
typeof fetchDeletionEvents === "function"
? fetchDeletionEvents
: (ids, opts) => this._defaultFetchDeletions(ids, opts);
if (!this.fetchVideoComments && this.nostrClient) {
const clientFetcher = this.nostrClient.fetchVideoComments;
@@ -162,6 +174,9 @@ export default class CommentThreadService {
this.metaById = new Map();
this.childrenByParent = new Map();
this.childrenByParent.set(ROOT_PARENT_KEY, []);
// NIP-09: target comment id -> Set of pubkeys that requested its deletion.
// A comment is treated as deleted only when its own author appears here.
this.deletionsByTarget = new Map();
this.profileCache = new Map();
this.profileQueue = new Set();
this.profileHydrationTimer = null;
@@ -306,6 +321,11 @@ export default class CommentThreadService {
});
}
// Honor NIP-09 deletions: drop comments whose author has since requested
// their removal. Applied during the (silent) initial pass; the emit below
// renders the final, deletions-applied thread.
await this.fetchAndApplyDeletions();
this.emitThreadReady();
if (this.profileHydrationTimer) {
@@ -656,6 +676,21 @@ export default class CommentThreadService {
return;
}
if (result.type === "skipped-deleted") {
return;
}
if (result.type === "deletion") {
// A removal can't be expressed as an append; re-render the thread when a
// comment actually disappeared (skip during the initial cache replay /
// bulk load, which emits once at the end).
if (!isInitial && result.removed) {
this.persistCommentCache();
this.emitThreadReady();
}
return;
}
if (!isInitial && result.type === "insert") {
this.emitAppend(result);
return;
@@ -679,6 +714,10 @@ export default class CommentThreadService {
return null;
}
if (isDeletionEvent(event)) {
return this.applyDeletion(event);
}
const parentId = this.extractParentId(event);
const parentKey = toParentKey(parentId);
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
@@ -702,6 +741,13 @@ export default class CommentThreadService {
}
const existingMeta = this.metaById.get(eventId);
// The comment may arrive after we already saw its author's deletion
// request (out-of-order relays). Honor the deletion and don't surface it.
if (!existingMeta && this.isCommentDeleted(eventId, pubkey)) {
return { type: "skipped-deleted", eventId };
}
this.eventsById.set(eventId, event);
this.metaById.set(eventId, { parentKey, createdAt });
@@ -759,6 +805,134 @@ export default class CommentThreadService {
return "";
}
// The comment ids a NIP-09 kind-5 request asks to delete (its `e` tags).
extractDeletionTargets(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
const ids = [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2 || tag[0] !== "e") {
continue;
}
const id = normalizeHexId(tag[1]);
if (id && !ids.includes(id)) {
ids.push(id);
}
}
return ids;
}
isCommentDeleted(eventId, authorPubkey) {
if (!eventId || !authorPubkey) {
return false;
}
const deleters = this.deletionsByTarget.get(eventId);
return Boolean(deleters && deleters.has(authorPubkey));
}
// Apply a NIP-09 deletion request. A comment is only removed when the
// deletion is signed by the comment's own author (you can't delete someone
// else's comment). Deletions are also remembered so a comment that arrives
// afterwards (out-of-order relays) is suppressed on insert.
applyDeletion(event) {
const deleter = normalizePubkey(event.pubkey);
if (!deleter) {
return { type: "deletion", removed: false };
}
const targets = this.extractDeletionTargets(event);
let removed = false;
for (const targetId of targets) {
let deleters = this.deletionsByTarget.get(targetId);
if (!deleters) {
deleters = new Set();
this.deletionsByTarget.set(targetId, deleters);
}
deleters.add(deleter);
const existing = this.eventsById.get(targetId);
if (existing && normalizePubkey(existing.pubkey) === deleter) {
this.removeComment(targetId);
removed = true;
}
}
return { type: "deletion", removed };
}
removeComment(eventId) {
const meta = this.metaById.get(eventId);
if (meta) {
this.removeFromParentList(meta.parentKey, eventId);
this.metaById.delete(eventId);
}
this.eventsById.delete(eventId);
// Replies to a removed comment become unreachable from the root and so are
// hidden along with their deleted parent; their child list is left in place
// but never walked by getSnapshot().
}
async fetchAndApplyDeletions() {
const commentIds = Array.from(this.eventsById.keys());
if (!commentIds.length || typeof this.fetchDeletionEvents !== "function") {
return;
}
let deletionEvents = [];
try {
deletionEvents = await this.fetchDeletionEvents(commentIds, {
relays: this.activeRelays,
});
} catch (error) {
logDev(
this.logger?.dev,
"[commentThread] Failed to fetch comment deletions:",
error,
);
return;
}
if (Array.isArray(deletionEvents)) {
for (const deletionEvent of deletionEvents) {
this.processIncomingEvent(deletionEvent, { isInitial: true });
}
}
}
async _defaultFetchDeletions(commentIds, { relays } = {}) {
const pool = this.nostrClient?.pool;
if (
!pool ||
typeof pool.list !== "function" ||
!Array.isArray(commentIds) ||
!commentIds.length
) {
return [];
}
const relayList =
Array.isArray(relays) && relays.length
? relays
: Array.isArray(this.nostrClient?.relays)
? this.nostrClient.relays
: [];
if (!relayList.length) {
return [];
}
try {
const results = await pool.list(relayList, [
{ kinds: [DELETION_EVENT_KIND], "#e": commentIds },
]);
if (!Array.isArray(results)) {
return [];
}
return results
.flat()
.filter((event) => event && typeof event === "object");
} catch (error) {
logDev(
this.logger?.dev,
"[commentThread] Default deletion fetch failed:",
error,
);
return [];
}
}
extractRootIdentifier(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (let index = tags.length - 1; index >= 0; index -= 1) {
+136
View File
@@ -1272,3 +1272,139 @@ test(
assert.equal(unsubscribeCalled, true, "teardown should invoke subscription cleanup");
},
);
// --- NIP-09 deletions ---
test("CommentThreadService removes a comment deleted by its own author (NIP-09)", async () => {
setImprovedCommentFetchingEnabled(false);
const videoId = DEFAULT_VIDEO_ID;
const authorA = "1".repeat(64);
const authorB = "2".repeat(64);
const topId = "b".repeat(64);
const replyId = "c".repeat(64);
const top = createComment({ id: topId, pubkey: authorA, createdAt: 100, videoId });
const reply = createComment({
id: replyId,
pubkey: authorB,
createdAt: 101,
parentId: topId,
videoId,
});
const deletion = {
id: "d".repeat(64),
kind: 5,
pubkey: authorA,
created_at: 102,
content: "",
tags: [["e", topId]],
};
const service = new CommentThreadService({
fetchVideoComments: async () => [top, reply],
subscribeVideoComments: () => () => {},
fetchDeletionEvents: async () => [deletion],
});
try {
await service.loadThread({ video: createBaseVideo() });
const snap = service.getSnapshot();
assert.equal(
snap.commentsById.has(topId),
false,
"an author-signed deletion removes the comment",
);
const rootChildren = snap.childrenByParent.get(null) || [];
assert.equal(
rootChildren.includes(topId),
false,
"deleted comment is gone from the root list",
);
assert.equal(
rootChildren.length,
0,
"no top-level comments remain after deleting the only one",
);
assert.equal(
rootChildren.includes(replyId),
false,
"the orphaned reply is not promoted to the root",
);
} finally {
service.teardown();
setImprovedCommentFetchingEnabled(true);
}
});
test("CommentThreadService ignores a deletion not signed by the comment author", async () => {
setImprovedCommentFetchingEnabled(false);
const videoId = DEFAULT_VIDEO_ID;
const authorA = "1".repeat(64);
const attacker = "9".repeat(64);
const topId = "b".repeat(64);
const top = createComment({ id: topId, pubkey: authorA, createdAt: 100, videoId });
const forgedDeletion = {
id: "e".repeat(64),
kind: 5,
pubkey: attacker,
created_at: 103,
content: "",
tags: [["e", topId]],
};
const service = new CommentThreadService({
fetchVideoComments: async () => [top],
subscribeVideoComments: () => () => {},
fetchDeletionEvents: async () => [forgedDeletion],
});
try {
await service.loadThread({ video: createBaseVideo() });
const snap = service.getSnapshot();
assert.equal(
snap.commentsById.has(topId),
true,
"a deletion from a non-author must NOT remove the comment",
);
assert.deepEqual(
snap.childrenByParent.get(null),
[topId],
"the comment stays in the root list",
);
} finally {
service.teardown();
setImprovedCommentFetchingEnabled(true);
}
});
test("CommentThreadService suppresses a comment that arrives after its author's deletion", async () => {
const videoId = DEFAULT_VIDEO_ID;
const authorA = "1".repeat(64);
const topId = "b".repeat(64);
const service = new CommentThreadService({ subscribeVideoComments: () => () => {} });
service.videoEventId = videoId;
const deletion = {
id: "d".repeat(64),
kind: 5,
pubkey: authorA,
created_at: 102,
content: "",
tags: [["e", topId]],
};
const top = createComment({ id: topId, pubkey: authorA, createdAt: 100, videoId });
// Deletion arrives BEFORE the comment (out-of-order relays).
service.processIncomingEvent(deletion, { isInitial: false });
service.processIncomingEvent(top, { isInitial: false });
const snap = service.getSnapshot();
assert.equal(
snap.commentsById.has(topId),
false,
"a comment is suppressed if its author's deletion was already seen",
);
service.teardown();
});