From 735d67313cd019fcdc06c55d46c6bae53ea951ff Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:42:15 -0500 Subject: [PATCH] Add shared hex normalization for comment flows --- js/services/commentThreadService.js | 15 ++--- js/ui/videoModalCommentController.js | 80 +++++++++++++-------------- js/utils/hex.js | 11 ++++ js/utils/videoPointer.js | 3 +- tests/comment-thread-service.test.mjs | 29 ++++++++++ tests/video-modal-comments.test.mjs | 53 +++++++----------- 6 files changed, 102 insertions(+), 89 deletions(-) create mode 100644 js/utils/hex.js diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index b7851310..3cb9805e 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -1,6 +1,7 @@ // js/services/commentThreadService.js import logger, { devLogger, userLogger } from "../utils/logger.js"; +import { normalizeHexId, normalizeHexPubkey } from "../utils/hex.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; import { FEATURE_IMPROVED_COMMENT_FETCHING } from "../constants.js"; @@ -24,11 +25,6 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } -function normalizeHexId(value) { - const normalized = normalizeString(value); - return normalized ? normalized.toLowerCase() : ""; -} - function normalizeRelay(value) { if (typeof value === "string") { return value.trim(); @@ -36,10 +32,7 @@ function normalizeRelay(value) { return ""; } -function normalizePubkey(pubkey) { - const normalized = normalizeString(pubkey); - return normalized ? normalized.toLowerCase() : ""; -} +const normalizePubkey = normalizeHexPubkey; function normalizeKind(value) { if (Number.isFinite(value)) { @@ -225,11 +218,11 @@ export default class CommentThreadService { this.videoKind = normalizeKind(video?.kind); this.videoAuthorPubkeyRaw = rawVideoAuthorPubkey; this.videoAuthorPubkey = normalizePubkey(rawVideoAuthorPubkey); - const pointerRootIdentifier = normalizeString( + const pointerRootIdentifier = normalizeHexId( video?.pointerIdentifiers?.videoRootId, ); this.videoRootIdentifier = - normalizeString(video?.videoRootId) || pointerRootIdentifier; + normalizeHexId(video?.videoRootId) || pointerRootIdentifier; this.videoRootRelay = normalizeRelay( video?.videoRootRelay || video?.rootIdentifierRelay, ); diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index d2b33c50..2331d61d 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -1,6 +1,7 @@ import { devLogger } from "../utils/logger.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; +import { normalizeHexString, normalizeHexPubkey } from "../utils/hex.js"; const DEFAULT_LIMIT = 40; @@ -8,9 +9,7 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } -function normalizeVideoEventId(value) { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} +const normalizeHexId = normalizeHexString; function normalizeRelay(value) { if (typeof value === "string") { @@ -88,7 +87,7 @@ export default class VideoModalCommentController { normalizeHexPubkey: typeof utils.normalizeHexPubkey === "function" ? (value) => utils.normalizeHexPubkey(value) - : () => null, + : (value) => normalizeHexPubkey(value) || null, }; this.currentVideo = null; @@ -156,19 +155,20 @@ export default class VideoModalCommentController { return; } - const videoEventId = normalizeString(video.id); + const videoEventId = normalizeHexId(video.id); let videoDefinitionAddress = buildVideoAddressPointer(video); if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) { videoDefinitionAddress = normalizeString( this.modalCommentState.videoDefinitionAddress, ); } + if (videoDefinitionAddress) { + videoDefinitionAddress = videoDefinitionAddress.trim().toLowerCase(); + } const videoKind = normalizeKind(video.kind); - const videoAuthorPubkey = normalizeString(video.pubkey); - const pointerRootId = normalizeString( - video.pointerIdentifiers?.videoRootId, - ); - const videoRootId = normalizeString(video.videoRootId) || pointerRootId; + const videoAuthorPubkey = normalizeHexPubkey(video.pubkey); + const pointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId); + const videoRootId = normalizeHexId(video.videoRootId) || pointerRootId; const videoRootRelay = normalizeRelay( video.videoRootRelay || video.rootIdentifierRelay, ); @@ -356,10 +356,8 @@ export default class VideoModalCommentController { return; } - const snapshotVideoEventId = normalizeVideoEventId(snapshot.videoEventId); - const modalVideoEventId = normalizeVideoEventId( - this.modalCommentState.videoEventId, - ); + const snapshotVideoEventId = normalizeHexId(snapshot.videoEventId); + const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId); if (!snapshotVideoEventId || snapshotVideoEventId !== modalVideoEventId) { return; @@ -368,9 +366,8 @@ export default class VideoModalCommentController { this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles, { normalizeKey: this.utils.normalizeHexPubkey, }); - this.modalCommentState.parentCommentId = normalizeString( - snapshot.parentCommentId, - ) || null; + this.modalCommentState.parentCommentId = + normalizeHexId(snapshot.parentCommentId) || null; if (typeof snapshot.videoDefinitionAddress === "string") { const pointer = normalizeString(snapshot.videoDefinitionAddress); if (pointer) { @@ -381,11 +378,11 @@ export default class VideoModalCommentController { if (snapshotVideoKind) { this.modalCommentState.videoKind = snapshotVideoKind; } - const snapshotVideoAuthor = normalizeString(snapshot.videoAuthorPubkey); + const snapshotVideoAuthor = normalizeHexPubkey(snapshot.videoAuthorPubkey); if (snapshotVideoAuthor) { this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor; } - const snapshotRootIdentifier = normalizeString(snapshot.rootIdentifier); + const snapshotRootIdentifier = normalizeHexId(snapshot.rootIdentifier); if (snapshotRootIdentifier) { this.modalCommentState.videoRootId = snapshotRootIdentifier; } @@ -397,7 +394,7 @@ export default class VideoModalCommentController { if (snapshotParentKind) { this.modalCommentState.parentCommentKind = snapshotParentKind; } - const snapshotParentPubkey = normalizeString(snapshot.parentCommentPubkey); + const snapshotParentPubkey = normalizeHexPubkey(snapshot.parentCommentPubkey); if (snapshotParentPubkey) { this.modalCommentState.parentCommentPubkey = snapshotParentPubkey; } @@ -413,10 +410,8 @@ export default class VideoModalCommentController { return; } - const payloadVideoEventId = normalizeVideoEventId(payload.videoEventId); - const modalVideoEventId = normalizeVideoEventId( - this.modalCommentState.videoEventId, - ); + const payloadVideoEventId = normalizeHexId(payload.videoEventId); + const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId); if (!payloadVideoEventId || payloadVideoEventId !== modalVideoEventId) { return; @@ -430,7 +425,7 @@ export default class VideoModalCommentController { this.modalCommentProfiles.set(pubkey, profile); }); - const payloadRootIdentifier = normalizeString(payload.rootIdentifier); + const payloadRootIdentifier = normalizeHexId(payload.rootIdentifier); if (payloadRootIdentifier) { this.modalCommentState.videoRootId = payloadRootIdentifier; } @@ -460,7 +455,7 @@ export default class VideoModalCommentController { if (eventKind) { this.modalCommentState.parentCommentKind = eventKind; } - const eventPubkey = normalizeString(event.pubkey); + const eventPubkey = normalizeHexPubkey(event.pubkey); if (eventPubkey) { this.modalCommentState.parentCommentPubkey = eventPubkey; } @@ -545,7 +540,7 @@ export default class VideoModalCommentController { ) { normalizedKey = null; } else { - normalizedKey = trimmed; + normalizedKey = normalizeHexId(trimmed) || trimmed; } } const list = Array.isArray(value) ? value.filter(Boolean) : []; @@ -592,9 +587,9 @@ export default class VideoModalCommentController { }); return { - videoEventId: snapshot.videoEventId, - parentCommentId: snapshot.parentCommentId || null, - rootIdentifier: snapshot.rootIdentifier || null, + videoEventId: normalizeHexId(snapshot.videoEventId), + parentCommentId: normalizeHexId(snapshot.parentCommentId) || null, + rootIdentifier: normalizeHexId(snapshot.rootIdentifier) || null, rootIdentifierRelay: snapshot.rootIdentifierRelay || null, commentsById: sanitizedComments, childrenByParent: sanitizedChildren, @@ -640,7 +635,7 @@ export default class VideoModalCommentController { return; } - const videoEventId = normalizeString(video.id); + const videoEventId = normalizeHexId(video.id); let videoDefinitionAddress = buildVideoAddressPointer(video); if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) { videoDefinitionAddress = normalizeString( @@ -648,18 +643,17 @@ export default class VideoModalCommentController { ); } const videoKind = - this.modalCommentState.videoKind || normalizeKind(video.kind); + normalizeKind(this.modalCommentState.videoKind) || normalizeKind(video.kind); const videoAuthorPubkey = - this.modalCommentState.videoAuthorPubkey || normalizeString(video.pubkey); - const fallbackPointerRootId = normalizeString( - video.pointerIdentifiers?.videoRootId, - ); + normalizeHexPubkey(this.modalCommentState.videoAuthorPubkey) || + normalizeHexPubkey(video.pubkey); + const fallbackPointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId); const videoRootId = - this.modalCommentState.videoRootId || - normalizeString(video.videoRootId) || + normalizeHexId(this.modalCommentState.videoRootId) || + normalizeHexId(video.videoRootId) || fallbackPointerRootId; const videoRootRelay = - this.modalCommentState.videoRootRelay || + normalizeRelay(this.modalCommentState.videoRootRelay) || normalizeRelay(video.videoRootRelay || video.rootIdentifierRelay); if (!videoEventId) { @@ -667,12 +661,12 @@ export default class VideoModalCommentController { return; } - const parentCommentId = normalizeString(detail.parentId) || null; + const parentCommentId = normalizeHexId(detail.parentId) || null; let parentCommentKind = this.modalCommentState.parentCommentKind ? normalizeKind(this.modalCommentState.parentCommentKind) : ""; let parentCommentPubkey = this.modalCommentState.parentCommentPubkey - ? normalizeString(this.modalCommentState.parentCommentPubkey) + ? normalizeHexPubkey(this.modalCommentState.parentCommentPubkey) : ""; if (parentCommentId && typeof this.commentThreadService?.getCommentEvent === "function") { @@ -685,7 +679,7 @@ export default class VideoModalCommentController { parentCommentKind = eventKind; this.modalCommentState.parentCommentKind = eventKind; } - const eventPubkey = normalizeString(parentEvent.pubkey); + const eventPubkey = normalizeHexPubkey(parentEvent.pubkey); if (eventPubkey) { parentCommentPubkey = eventPubkey; this.modalCommentState.parentCommentPubkey = eventPubkey; @@ -874,7 +868,7 @@ export default class VideoModalCommentController { } async handleVideoModalCommentMute(detail = {}) { - const pubkey = normalizeString(detail?.pubkey); + const pubkey = normalizeHexPubkey(detail?.pubkey); if (!pubkey) { return; } diff --git a/js/utils/hex.js b/js/utils/hex.js new file mode 100644 index 00000000..367f78bd --- /dev/null +++ b/js/utils/hex.js @@ -0,0 +1,11 @@ +export function normalizeHexString(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + +export const normalizeHexId = normalizeHexString; +export const normalizeHexPubkey = normalizeHexString; diff --git a/js/utils/videoPointer.js b/js/utils/videoPointer.js index 292ef31d..10358362 100644 --- a/js/utils/videoPointer.js +++ b/js/utils/videoPointer.js @@ -2,6 +2,7 @@ import { pointerArrayToKey } from "./pointer.js"; import { devLogger } from "./logger.js"; +import { normalizeHexPubkey } from "./hex.js"; const DEFAULT_VIDEO_KIND = 30078; @@ -84,7 +85,7 @@ export function buildVideoAddressPointer( return ""; } - const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + const pubkey = normalizeHexPubkey(video.pubkey); if (!pubkey) { return ""; } diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index 7de85f41..3c3b2666 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -387,6 +387,35 @@ test("CommentThreadService normalizes mixed-case event ids in thread state", asy ); }); +test("CommentThreadService normalizes mixed-case pubkeys during hydration", async () => { + const video = createBaseVideo(); + const mixedPubkey = "PuBkEy-Upper"; + + const fetchVideoComments = async () => [ + createComment({ id: "c-hydrate", pubkey: mixedPubkey, createdAt: 1700000200 }), + ]; + + const hydrationRequests = []; + const service = new CommentThreadService({ + nostrClient: { ensurePool: async () => {} }, + fetchVideoComments, + subscribeVideoComments: () => () => {}, + batchFetchProfiles: async (pubkeys) => { + hydrationRequests.push([...pubkeys]); + return []; + }, + hydrationDebounceMs: 0, + }); + + await service.loadThread({ video }); + + assert.deepEqual( + hydrationRequests, + [[mixedPubkey.toLowerCase()]], + "hydration should request normalized pubkeys", + ); +}); + test( "listVideoComments accepts builder events without parent ids and filters replies", async () => { diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 235c39c3..4521ed44 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -145,10 +145,6 @@ test( auth: { isLoggedIn: () => true, }, - utils: { - normalizeHexPubkey: (value) => - typeof value === "string" ? value.toLowerCase() : null, - }, }); controller.load({ id: "VideoCase", enableComments: true }); @@ -357,9 +353,6 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); const video = { @@ -380,8 +373,8 @@ test( assert.equal(publishCalls.length, 1, "publishComment should be called once"); const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); - assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); - assert.equal(payload.videoDefinitionAddress, "30078:AUTHORPK:video-root"); + 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"); }, @@ -419,9 +412,6 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); const video = { @@ -436,7 +426,7 @@ test( controller.load(video); controller.modalCommentState.videoDefinitionAddress = - " 30078:AUTHORPK:video-root "; + " 30078:authorpk:video-root "; controller.modalCommentState.videoRootId = " video-root "; controller.modalCommentState.videoRootRelay = " wss://root.example "; @@ -448,7 +438,7 @@ test( const payload = publishCalls[0]; assert.equal( payload.videoDefinitionAddress, - "30078:AUTHORPK:video-root", + "30078:authorpk:video-root", "publish payload should reuse pointer from the loaded thread", ); assert.equal(payload.rootIdentifier, "video-root"); @@ -461,11 +451,12 @@ test( async () => { const publishCalls = []; const loadTargets = []; + let threadReady = null; const controller = new VideoModalCommentController({ commentThreadService: { setCallbacks: ({ onThreadReady }) => { - controller.commentThreadServiceReady = onThreadReady; + threadReady = onThreadReady; }, teardown: () => {}, defaultLimit: 10, @@ -495,11 +486,10 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); + controller.commentThreadServiceReady = threadReady; + const video = { id: "video789", pubkey: "AUTHORPK", @@ -514,8 +504,8 @@ test( 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"); + assert.equal(loadTargets[0]?.video?.videoRootId, "root-only"); + assert.equal(loadTargets[0]?.video?.videoRootRelay, "wss://root-only"); const commentEvent = { id: "comment-root", @@ -582,9 +572,6 @@ test( return { ok: true, event: { id: "comment", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value, - }, }); const video = { @@ -601,7 +588,11 @@ test( await Promise.resolve(); assert.equal(loadTargets.length, 1, "loadThread should capture pointer target"); - assert.equal(loadTargets[0].rootIdentifier, "pointer-root"); + assert.equal( + loadTargets[0]?.video?.pointerIdentifiers?.videoRootId, + "pointer-root", + "pointerIdentifiers should carry the normalized root id", + ); controller.submit({ text: "Pointer fallback" }); await controller.modalCommentPublishPromise; @@ -663,9 +654,6 @@ test( }; }, }, - utils: { - normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, - }, }); controller.currentVideo = { @@ -685,9 +673,9 @@ test( videoEventId: "legacy-video", parentCommentId: null, videoKind: "30078", - videoAuthorPubkey: "AUTHORPK", + videoAuthorPubkey: "authorpk", rootKind: "30078", - rootAuthorPubkey: "AUTHORPK", + rootAuthorPubkey: "authorpk", }); assert.equal( "videoDefinitionAddress" in payload, @@ -845,9 +833,6 @@ test( return { ok: true, event: { id: "reply", tags: [] } }; }, }, - utils: { - normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, - }, }); controller.currentVideo = { @@ -877,12 +862,12 @@ test( const payload = publishCalls[0]; assert.equal(payload.videoEventId, "video123"); assert.equal(payload.videoKind, "30078"); - assert.equal(payload.videoAuthorPubkey, "AUTHORPK"); + assert.equal(payload.videoAuthorPubkey, "authorpk"); assert.equal(payload.parentCommentId, "parent-1"); assert.equal(payload.parentCommentKind, String(COMMENT_EVENT_KIND)); assert.equal(payload.parentCommentPubkey, "parentpk"); assert.equal(payload.parentAuthorPubkey, "parentpk"); - assert.equal(payload.rootAuthorPubkey, "AUTHORPK"); + assert.equal(payload.rootAuthorPubkey, "authorpk"); }, );