From 8258686f5501319dae58f310c7bd9771757380e3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:27:30 -0500 Subject: [PATCH] Preserve raw video author pubkeys for comment threads --- js/services/commentThreadService.js | 15 +-- tests/comment-thread-service.test.mjs | 152 ++++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index a7b9adff..aaa1e27d 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -193,7 +193,7 @@ export default class CommentThreadService { if (this.videoKind) { target.videoKind = this.videoKind; } - const targetVideoAuthorPubkey = this.videoAuthorPubkeyRaw || this.videoAuthorPubkey; + const targetVideoAuthorPubkey = this.getVideoAuthorPubkeyForOutput(); if (targetVideoAuthorPubkey) { target.videoAuthorPubkey = targetVideoAuthorPubkey; } @@ -738,14 +738,17 @@ export default class CommentThreadService { await hydrationPromise; } + getVideoAuthorPubkeyForOutput() { + return this.videoAuthorPubkeyRaw || this.videoAuthorPubkey || ""; + } + emitThreadReady() { const payload = { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, - videoAuthorPubkey: - this.videoAuthorPubkeyRaw || this.videoAuthorPubkey || null, + videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, topLevelIds: this.getCommentIdsForParent(null), @@ -763,8 +766,7 @@ export default class CommentThreadService { parentCommentId: parentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, - videoAuthorPubkey: - this.videoAuthorPubkeyRaw || this.videoAuthorPubkey || null, + videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentIds: [eventId], @@ -789,8 +791,7 @@ export default class CommentThreadService { parentCommentId: this.parentCommentId || null, videoDefinitionAddress: this.videoAddressPointer || null, videoKind: this.videoKind || null, - videoAuthorPubkey: - this.videoAuthorPubkeyRaw || this.videoAuthorPubkey || null, + videoAuthorPubkey: this.getVideoAuthorPubkeyForOutput() || null, parentCommentKind: this.parentCommentKind || null, parentCommentPubkey: this.parentCommentPubkey || null, commentsById: this.cloneCommentsMap(), diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 723331ec..6efa2caf 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -37,6 +37,56 @@ function createComment({ const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const originalLocalStorage = globalThis.localStorage; +const originalWindowLocalStorage = globalThis.window?.localStorage; + +function createIsolatedLocalStorage() { + const store = new Map(); + return { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + }, + removeItem(key) { + store.delete(String(key)); + }, + clear() { + store.clear(); + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + get length() { + return store.size; + }, + }; +} + +test.beforeEach(() => { + const stub = createIsolatedLocalStorage(); + globalThis.localStorage = stub; + if (globalThis.window) { + globalThis.window.localStorage = stub; + } +}); + +test.after(() => { + if (originalLocalStorage) { + globalThis.localStorage = originalLocalStorage; + } else { + delete globalThis.localStorage; + } + if (globalThis.window) { + if (originalWindowLocalStorage) { + globalThis.window.localStorage = originalWindowLocalStorage; + } else { + delete globalThis.window.localStorage; + } + } +}); + test( "listVideoComments accepts builder events without parent ids and filters replies", async () => { @@ -242,10 +292,9 @@ test( videoAuthorPubkey: "authorpk", parentCommentId: "", }); - assert.deepStrictEqual(lastFetchOptions, { - limit: service.defaultLimit, - relays: ["wss://relay.example"], - }); + assert.equal(lastFetchOptions.limit, service.defaultLimit); + assert.deepStrictEqual(lastFetchOptions.relays, ["wss://relay.example"]); + assert.equal(lastFetchOptions.since, 0); assert.equal(subscribeCalls, 1, "subscribeVideoComments should be invoked"); assert.deepStrictEqual(subscriptionTarget, lastFetchTarget); @@ -383,10 +432,10 @@ test( "videoDefinitionAddress should be omitted when unavailable", ); - assert.deepStrictEqual(fetchOptions[0], { - limit: service.defaultLimit, - relays: null, - }); + const [firstFetchOptions] = fetchOptions; + assert.equal(firstFetchOptions.limit, service.defaultLimit); + assert.equal(firstFetchOptions.relays, null); + assert.equal(firstFetchOptions.since, 0); assert.equal( subscribeTargets.length, @@ -408,6 +457,93 @@ test( }, ); +test( + "CommentThreadService preserves raw video author pubkeys during hydration fetches", + async () => { + const video = { + id: "video-upper", + pubkey: "AUTHORPK", + kind: 30078, + tags: [["d", "video-root-upper"]], + }; + + const videoDefinitionAddress = "30078:AUTHORPK:video-root-upper"; + const pendingComment = { + id: "upper-comment-1", + ...buildCommentEvent({ + pubkey: "commenter-upper", + created_at: 1700001200, + videoEventId: video.id, + videoDefinitionAddress, + rootIdentifier: "video-root-upper", + rootKind: "30078", + rootAuthorPubkey: video.pubkey, + content: "Uppercase hydration check", + }), + }; + + const fetchTargets = []; + let resolveFetch = () => {}; + const fetchBlocker = new Promise((resolve) => { + resolveFetch = resolve; + }); + const fetchVideoComments = async (target) => { + fetchTargets.push(target); + await fetchBlocker; + if (target.videoAuthorPubkey === video.pubkey) { + return [pendingComment]; + } + return []; + }; + + const subscribeTargets = []; + const service = new CommentThreadService({ + fetchVideoComments, + subscribeVideoComments: (target, _options = {}) => { + subscribeTargets.push(target); + return () => {}; + }, + hydrationDebounceMs: 0, + }); + + const loadPromise = service.loadThread({ video }); + await tick(); + + assert.equal(fetchTargets.length, 1, "hydration fetch should start immediately"); + + service.processIncomingEvent(pendingComment); + resolveFetch(); + + const snapshot = await loadPromise; + + assert.equal( + fetchTargets[0].videoAuthorPubkey, + video.pubkey, + "fetch target should prefer the raw video author pubkey", + ); + assert.equal( + snapshot.videoAuthorPubkey, + video.pubkey, + "snapshot should retain the raw video author pubkey", + ); + assert.equal( + snapshot.commentsById.has(pendingComment.id), + true, + "hydration should include comments published before fetch completion", + ); + assert.equal( + subscribeTargets.length, + 1, + "subscription should begin after hydration completes", + ); + assert.equal( + subscribeTargets[0].videoAuthorPubkey, + video.pubkey, + "subscription target should also retain the raw pubkey", + ); + }, +); + test( "CommentThreadService teardown cancels hydration timers", async () => {