Add root identifier plumbing to modal comments

This commit is contained in:
thePR0M3TH3AN committed 2025-11-12 20:31:43 -05:00
1 parent 6ae8e6ee19
commit 74bbe8e078
4 files changed
+259 -1

No files matched your search

+56
View File
@@ -24,6 +24,13 @@ function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRelay(value) {
if (typeof value === "string") {
return value.trim();
}
return "";
}
function normalizePubkey(pubkey) {
const normalized = normalizeString(pubkey);
return normalized ? normalized.toLowerCase() : "";
@@ -132,6 +139,8 @@ export default class CommentThreadService {
this.videoKind = "";
this.videoAuthorPubkeyRaw = "";
this.videoAuthorPubkey = "";
this.videoRootIdentifier = "";
this.videoRootRelay = "";
this.parentCommentId = "";
this.parentCommentKind = "";
this.parentCommentPubkey = "";
@@ -182,6 +191,10 @@ export default class CommentThreadService {
this.videoKind = normalizeKind(video?.kind);
this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey;
this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey);
this.videoRootIdentifier = normalizeString(video?.videoRootId);
this.videoRootRelay = normalizeRelay(
video?.videoRootRelay || video?.rootIdentifierRelay,
);
this.parentCommentId = normalizeString(parentCommentId);
this.parentCommentKind = this.parentCommentId ? String(COMMENT_EVENT_KIND) : "";
this.parentCommentPubkey = "";
@@ -213,6 +226,12 @@ export default class CommentThreadService {
if (this.videoKind) {
target.videoKind = this.videoKind;
}
if (this.videoRootIdentifier) {
target.rootIdentifier = this.videoRootIdentifier;
}
if (this.videoRootRelay) {
target.rootIdentifierRelay = this.videoRootRelay;
}
const targetVideoAuthorPubkey = this.getVideoAuthorPubkeyForOutput();
if (targetVideoAuthorPubkey) {
target.videoAuthorPubkey = targetVideoAuthorPubkey;
@@ -563,6 +582,14 @@ export default class CommentThreadService {
const parentKey = toParentKey(parentId);
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
const pubkey = normalizePubkey(event.pubkey);
const { identifier: rootIdentifier, relay: rootRelay } =
this.extractRootIdentifier(event);
if (rootIdentifier) {
this.videoRootIdentifier = rootIdentifier;
if (rootRelay) {
this.videoRootRelay = rootRelay;
}
}
if (this.parentCommentId && eventId === this.parentCommentId) {
const kindValue = normalizeKind(event.kind);
if (kindValue) {
@@ -627,6 +654,29 @@ export default class CommentThreadService {
return "";
}
extractRootIdentifier(event) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const name = typeof tag[0] === "string" ? tag[0].trim() : "";
if (name !== "I") {
continue;
}
const value = normalizeString(tag[1]);
if (!value) {
continue;
}
return {
identifier: value,
relay: normalizeRelay(tag[2]),
};
}
return { identifier: "", relay: "" };
}
insertIntoParentList(parentKey, eventId, createdAt) {
const list = this.getParentList(parentKey);
if (list.includes(eventId)) {
@@ -782,6 +832,8 @@ export default class CommentThreadService {
videoDefinitionAddress: this.videoAddressPointer || null,
videoKind: this.videoKind || null,
videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null,
rootIdentifier: this.videoRootIdentifier || null,
rootIdentifierRelay: this.videoRootRelay || null,
parentCommentKind: this.parentCommentKind || null,
parentCommentPubkey: this.parentCommentPubkey || null,
topLevelIds: this.getCommentIdsForParent(null),
@@ -800,6 +852,8 @@ export default class CommentThreadService {
videoDefinitionAddress: this.videoAddressPointer || null,
videoKind: this.videoKind || null,
videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null,
rootIdentifier: this.videoRootIdentifier || null,
rootIdentifierRelay: this.videoRootRelay || null,
parentCommentKind: this.parentCommentKind || null,
parentCommentPubkey: this.parentCommentPubkey || null,
commentIds: [eventId],
@@ -825,6 +879,8 @@ export default class CommentThreadService {
videoDefinitionAddress: this.videoAddressPointer || null,
videoKind: this.videoKind || null,
videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null,
rootIdentifier: this.videoRootIdentifier || null,
rootIdentifierRelay: this.videoRootRelay || null,
parentCommentKind: this.parentCommentKind || null,
parentCommentPubkey: this.parentCommentPubkey || null,
commentsById: this.cloneCommentsMap(),
+47
View File
@@ -8,6 +8,13 @@ function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRelay(value) {
if (typeof value === "string") {
return value.trim();
}
return "";
}
function normalizeKind(value) {
if (Number.isFinite(value)) {
return String(Math.floor(value));
@@ -86,6 +93,8 @@ export default class VideoModalCommentController {
videoDefinitionAddress: null,
videoKind: null,
videoAuthorPubkey: null,
videoRootId: null,
videoRootRelay: null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: null,
@@ -152,6 +161,10 @@ export default class VideoModalCommentController {
}
const videoKind = normalizeKind(video.kind);
const videoAuthorPubkey = normalizeString(video.pubkey);
const videoRootId = normalizeString(video.videoRootId);
const videoRootRelay = normalizeRelay(
video.videoRootRelay || video.rootIdentifierRelay,
);
if (!videoEventId) {
this.resetModalCommentState({ hide: false });
@@ -166,6 +179,8 @@ export default class VideoModalCommentController {
videoDefinitionAddress: videoDefinitionAddress || null,
videoKind: videoKind || null,
videoAuthorPubkey: videoAuthorPubkey || null,
videoRootId: videoRootId || null,
videoRootRelay: videoRootRelay || null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: null,
@@ -248,6 +263,8 @@ export default class VideoModalCommentController {
videoDefinitionAddress: null,
videoKind: null,
videoAuthorPubkey: null,
videoRootId: null,
videoRootRelay: null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: null,
@@ -354,6 +371,14 @@ export default class VideoModalCommentController {
if (snapshotVideoAuthor) {
this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor;
}
const snapshotRootIdentifier = normalizeString(snapshot.rootIdentifier);
if (snapshotRootIdentifier) {
this.modalCommentState.videoRootId = snapshotRootIdentifier;
}
const snapshotRootRelay = normalizeRelay(snapshot.rootIdentifierRelay);
if (snapshotRootRelay) {
this.modalCommentState.videoRootRelay = snapshotRootRelay;
}
const snapshotParentKind = normalizeKind(snapshot.parentCommentKind);
if (snapshotParentKind) {
this.modalCommentState.parentCommentKind = snapshotParentKind;
@@ -384,6 +409,15 @@ export default class VideoModalCommentController {
this.modalCommentProfiles.set(pubkey, profile);
});
const payloadRootIdentifier = normalizeString(payload.rootIdentifier);
if (payloadRootIdentifier) {
this.modalCommentState.videoRootId = payloadRootIdentifier;
}
const payloadRootRelay = normalizeRelay(payload.rootIdentifierRelay);
if (payloadRootRelay) {
this.modalCommentState.videoRootRelay = payloadRootRelay;
}
const commentIds = Array.isArray(payload.commentIds)
? payload.commentIds
: [];
@@ -520,6 +554,8 @@ export default class VideoModalCommentController {
return {
videoEventId: snapshot.videoEventId,
parentCommentId: snapshot.parentCommentId || null,
rootIdentifier: snapshot.rootIdentifier || null,
rootIdentifierRelay: snapshot.rootIdentifierRelay || null,
commentsById: sanitizedComments,
childrenByParent: sanitizedChildren,
profiles: this.modalCommentProfiles,
@@ -575,6 +611,11 @@ export default class VideoModalCommentController {
this.modalCommentState.videoKind || normalizeKind(video.kind);
const videoAuthorPubkey =
this.modalCommentState.videoAuthorPubkey || normalizeString(video.pubkey);
const videoRootId =
this.modalCommentState.videoRootId || normalizeString(video.videoRootId);
const videoRootRelay =
this.modalCommentState.videoRootRelay ||
normalizeRelay(video.videoRootRelay || video.rootIdentifierRelay);
if (!videoEventId) {
this.callbacks.showError("Comments are unavailable for this video.");
@@ -632,6 +673,12 @@ export default class VideoModalCommentController {
publishPayload.videoAuthorPubkey = videoAuthorPubkey;
publishPayload.rootAuthorPubkey = videoAuthorPubkey;
}
if (videoRootId) {
publishPayload.rootIdentifier = videoRootId;
}
if (videoRootRelay) {
publishPayload.rootIdentifierRelay = videoRootRelay;
}
if (parentCommentKind) {
publishPayload.parentCommentKind = parentCommentKind;
}
+55 -1
View File
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import CommentThreadService from "../js/services/commentThreadService.js";
import { listVideoComments } from "../js/nostr/commentEvents.js";
import { COMMENT_EVENT_KIND, listVideoComments } from "../js/nostr/commentEvents.js";
import { buildCommentEvent } from "../js/nostrEventSchemas.js";
function createBaseVideo() {
@@ -11,6 +11,7 @@ function createBaseVideo() {
pubkey: "authorpk",
kind: 30078,
tags: [["d", "video-root"], ["alt", "ignored"]],
videoRootId: "video-root",
};
}
@@ -256,6 +257,7 @@ test(
const hydrationRequests = [];
const service = new CommentThreadService({
nostrClient: { ensurePool: async () => {} },
fetchVideoComments,
subscribeVideoComments,
getProfileCacheEntry: (pubkey) =>
@@ -290,6 +292,7 @@ test(
videoDefinitionAddress: "30078:authorpk:video-root",
videoKind: "30078",
videoAuthorPubkey: "authorpk",
rootIdentifier: "video-root",
parentCommentId: "",
});
assert.equal(lastFetchOptions.limit, service.defaultLimit);
@@ -308,6 +311,7 @@ test(
assert.equal(initialSnapshot.videoDefinitionAddress, "30078:authorpk:video-root");
assert.equal(initialSnapshot.videoKind, "30078");
assert.equal(initialSnapshot.videoAuthorPubkey, "authorpk");
assert.equal(initialSnapshot.rootIdentifier, "video-root");
assert.equal(initialSnapshot.parentCommentKind, null);
assert.equal(initialSnapshot.parentCommentPubkey, null);
assert.deepStrictEqual(initialSnapshot.topLevelIds, [
@@ -358,6 +362,7 @@ test(
assert.equal(appendPayload.parentCommentId, "comment-2");
assert.deepStrictEqual(appendPayload.commentIds, ["reply-2"]);
assert.ok(appendPayload.commentsById instanceof Map);
assert.equal(appendPayload.rootIdentifier, "video-root");
assert.ok(
appendPayload.commentsById.has("reply-2"),
"appended payload should contain the new reply",
@@ -401,6 +406,7 @@ test(
const errors = [];
const service = new CommentThreadService({
nostrClient: { ensurePool: async () => {} },
fetchVideoComments: async (target, options) => {
fetchTargets.push(target);
fetchOptions.push(options);
@@ -424,6 +430,7 @@ test(
videoEventId: "video123",
videoKind: "30078",
videoAuthorPubkey: "authorpk",
rootIdentifier: "video-root",
parentCommentId: "",
});
assert.equal(
@@ -457,6 +464,51 @@ test(
},
);
test(
"CommentThreadService requests and snapshots comments by root identifier",
async () => {
const video = {
id: "root-only-video",
pubkey: "rootpk",
kind: 30078,
videoRootId: "root-only",
tags: [],
};
const rootOnlyComment = {
id: "comment-root",
kind: COMMENT_EVENT_KIND,
pubkey: "commenter",
created_at: 1700000000,
content: "Root scoped",
tags: [["I", "root-only"], ["p", "rootpk"]],
};
let fetchTarget = null;
const service = new CommentThreadService({
nostrClient: { ensurePool: async () => {} },
fetchVideoComments: async (target) => {
fetchTarget = target;
return [rootOnlyComment];
},
subscribeVideoComments: () => () => {},
});
const threadReadyPayloads = [];
service.setCallbacks({
onThreadReady: (payload) => threadReadyPayloads.push(payload),
});
const snapshot = await service.loadThread({ video });
assert.ok(fetchTarget, "fetch should run when only root id is provided");
assert.equal(fetchTarget.rootIdentifier, "root-only");
assert.equal(snapshot.rootIdentifier, "root-only");
assert.equal(snapshot.commentsById.get("comment-root").content, "Root scoped");
assert.equal(threadReadyPayloads[0].rootIdentifier, "root-only");
},
);
test(
"CommentThreadService preserves raw video author pubkeys during hydration fetches",
async () => {
@@ -498,6 +550,7 @@ test(
const subscribeTargets = [];
const service = new CommentThreadService({
nostrClient: { ensurePool: async () => {} },
fetchVideoComments,
subscribeVideoComments: (target, _options = {}) => {
subscribeTargets.push(target);
@@ -555,6 +608,7 @@ test(
let subscriptionHandler = null;
const service = new CommentThreadService({
nostrClient: { ensurePool: async () => {} },
fetchVideoComments: async () => [],
subscribeVideoComments: (_target, options = {}) => {
subscriptionHandler = typeof options.onEvent === "function" ? options.onEvent : null;
+101
View File
@@ -230,6 +230,8 @@ test(
enableComments: true,
kind: 30078,
tags: [["d", "video-root"]],
videoRootId: "video-root",
videoRootRelay: "wss://root.example",
};
controller.load(video);
@@ -242,6 +244,8 @@ test(
assert.equal(payload.videoEventId, "video123");
assert.equal(payload.videoAuthorPubkey, "AUTHORPK");
assert.equal(payload.videoDefinitionAddress, "30078:AUTHORPK:video-root");
assert.equal(payload.rootIdentifier, "video-root");
assert.equal(payload.rootIdentifierRelay, "wss://root.example");
},
);
@@ -287,12 +291,16 @@ test(
pubkey: "AUTHORPK",
enableComments: true,
kind: 30078,
videoRootId: "video-root",
videoRootRelay: "wss://root.example",
// No tags provided; buildVideoAddressPointer should return an empty string.
};
controller.load(video);
controller.modalCommentState.videoDefinitionAddress =
" 30078:AUTHORPK:video-root ";
controller.modalCommentState.videoRootId = " video-root ";
controller.modalCommentState.videoRootRelay = " wss://root.example ";
controller.submit({ text: "Fallback pointer" });
@@ -305,6 +313,97 @@ test(
"30078:AUTHORPK:video-root",
"publish payload should reuse pointer from the loaded thread",
);
assert.equal(payload.rootIdentifier, "video-root");
assert.equal(payload.rootIdentifierRelay, "wss://root.example");
},
);
test(
"VideoModalCommentController preserves videoRootId when only root pointer is provided",
async () => {
const publishCalls = [];
const loadTargets = [];
const controller = new VideoModalCommentController({
commentThreadService: {
setCallbacks: ({ onThreadReady }) => {
controller.commentThreadServiceReady = onThreadReady;
},
teardown: () => {},
defaultLimit: 10,
loadThread: async (target) => {
loadTargets.push(target);
return {};
},
processIncomingEvent: () => {},
},
videoModal: {
setCommentSectionCallbacks: () => {},
hideCommentsDisabledMessage: () => {},
showCommentsDisabledMessage: () => {},
setCommentsVisibility: () => {},
clearComments: () => {},
resetCommentComposer: () => {},
setCommentStatus: () => {},
setCommentComposerState: () => {},
renderComments: () => {},
},
auth: {
isLoggedIn: () => true,
},
services: {
publishComment: async (payload) => {
publishCalls.push(payload);
return { ok: true, event: { id: "comment", tags: [] } };
},
},
utils: {
normalizeHexPubkey: (value) => value,
},
});
const video = {
id: "video789",
pubkey: "AUTHORPK",
enableComments: true,
kind: 30078,
videoRootId: "root-only",
videoRootRelay: "wss://root-only",
tags: [],
};
controller.load(video);
await Promise.resolve();
assert.equal(loadTargets.length, 1, "loadThread should be invoked once");
assert.equal(loadTargets[0].rootIdentifier, "root-only");
assert.equal(loadTargets[0].rootIdentifierRelay, "wss://root-only");
const commentEvent = {
id: "comment-root",
pubkey: "commenter",
kind: COMMENT_EVENT_KIND,
content: "Root pointer only",
tags: [["I", "root-only"], ["p", "AUTHORPK"]],
};
controller.handleCommentThreadReady({
videoEventId: "video789",
parentCommentId: null,
rootIdentifier: "root-only",
rootIdentifierRelay: "wss://root-only",
commentsById: new Map([["comment-root", commentEvent]]),
childrenByParent: new Map([[null, ["comment-root"]]]),
profiles: new Map(),
});
controller.submit({ text: "Posting" });
await controller.modalCommentPublishPromise;
assert.equal(publishCalls.length, 1, "publishComment should use root pointer");
const payload = publishCalls[0];
assert.equal(payload.rootIdentifier, "root-only");
assert.equal(payload.rootIdentifierRelay, "wss://root-only");
},
);
@@ -557,6 +656,8 @@ test(
videoDefinitionAddress: null,
videoKind: "30078",
videoAuthorPubkey: "AUTHORPK",
videoRootId: null,
videoRootRelay: null,
parentCommentId: "parent-1",
parentCommentKind: String(COMMENT_EVENT_KIND),
parentCommentPubkey: null,