From 56b31f324e0de50a703e773493e34c30b1ad7f4f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:24:10 -0400 Subject: [PATCH] Improve comment metadata propagation --- js/nostr/commentEvents.js | 392 +++++++++++++++++++++++--- js/services/commentThreadService.js | 65 +++++ js/ui/videoModalCommentController.js | 107 +++++++ tests/comment-thread-service.test.mjs | 12 + tests/nostr/comment-events.test.mjs | 44 +++ tests/video-modal-comments.test.mjs | 77 +++++ 6 files changed, 655 insertions(+), 42 deletions(-) diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 0bfbc093..25356593 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -63,8 +63,10 @@ function normalizePointerCandidate(candidate, expectedType) { } if (Array.isArray(candidate)) { + const tagName = + typeof candidate[0] === "string" ? candidate[0].trim().toLowerCase() : ""; if ( - candidate[0] === expectedType && + tagName === expectedType && typeof candidate[1] === "string" && candidate[1].trim() ) { @@ -96,7 +98,7 @@ function normalizePointerCandidate(candidate, expectedType) { if (candidate && typeof candidate === "object") { if ( typeof candidate.type === "string" && - candidate.type === expectedType && + candidate.type.toLowerCase() === expectedType && typeof candidate.value === "string" && candidate.value.trim() ) { @@ -145,6 +147,74 @@ function pickString(...candidates) { return ""; } +function pickKind(...candidates) { + for (const candidate of candidates) { + if (Number.isFinite(candidate)) { + return String(Math.floor(candidate)); + } + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return ""; +} + +function isEventCandidate(candidate) { + if (!candidate || typeof candidate !== "object") { + return false; + } + if (Array.isArray(candidate.tags)) { + return true; + } + if (typeof candidate.id === "string" && candidate.id.trim()) { + return true; + } + if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) { + return true; + } + return false; +} + +function resolveEventCandidate(...candidates) { + for (const candidate of candidates) { + if (isEventCandidate(candidate)) { + return candidate; + } + } + return null; +} + +function collectTagsFromEvent(event) { + if (!event || typeof event !== "object") { + return []; + } + const tags = Array.isArray(event.tags) ? event.tags : []; + return tags.filter((tag) => Array.isArray(tag) && tag.length >= 2); +} + +function findTagByName(tags, ...names) { + const normalizedNames = names + .flat() + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean); + if (!normalizedNames.length) { + return null; + } + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const [name] = tag; + if (typeof name !== "string") { + continue; + } + if (normalizedNames.includes(name.trim())) { + return tag; + } + } + return null; +} + function normalizeCommentTarget(targetInput = {}, overrides = {}) { const target = targetInput && typeof targetInput === "object" ? targetInput : {}; const options = overrides && typeof overrides === "object" ? overrides : {}; @@ -165,6 +235,18 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { normalizePointerCandidate(target.definitionPointer, "a") || normalizePointerCandidate(target.definition, "a"); + const rootDefinitionPointer = + normalizePointerCandidate(options.rootDefinitionPointer, "a") || + normalizePointerCandidate(options.rootPointer, "a") || + normalizePointerCandidate(target.rootDefinitionPointer, "a") || + normalizePointerCandidate(target.rootPointer, "a"); + + const rootEventPointer = + normalizePointerCandidate(options.rootEventPointer, "e") || + normalizePointerCandidate(options.rootPointer, "e") || + normalizePointerCandidate(target.rootEventPointer, "e") || + normalizePointerCandidate(target.rootPointer, "e"); + const parentCommentPointer = normalizePointerCandidate(options.parentCommentPointer, "e") || normalizePointerCandidate(options.parentComment, "e") || @@ -172,17 +254,41 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { normalizePointerCandidate(target.parentComment, "e") || normalizePointerCandidate(target.parentPointer, "e"); + const videoEventObject = resolveEventCandidate( + options.videoEvent, + options.rootEvent, + target.videoEvent, + target.rootEvent, + ); + + const parentCommentEvent = resolveEventCandidate( + options.parentCommentEvent, + options.parentComment, + target.parentCommentEvent, + target.parentComment, + ); + + const rootEventTags = collectTagsFromEvent(videoEventObject); + const parentEventTags = collectTagsFromEvent(parentCommentEvent); + const combinedTags = [...parentEventTags, ...rootEventTags]; + const getTagField = (tag, index) => + Array.isArray(tag) && typeof tag[index] === "string" + ? tag[index].trim() + : ""; + const videoEventId = pickString( options.videoEventId, target.videoEventId, target.eventId, videoEventPointer?.value, + rootEventPointer?.value, ); const videoEventRelay = pickString( options.videoEventRelay, target.videoEventRelay, target.eventRelay, videoEventPointer?.relay, + rootEventPointer?.relay, ); const videoDefinitionAddress = pickString( @@ -190,12 +296,14 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { target.videoDefinitionAddress, target.definitionAddress, videoDefinitionPointer?.value, + rootDefinitionPointer?.value, ); const videoDefinitionRelay = pickString( options.videoDefinitionRelay, target.videoDefinitionRelay, target.definitionRelay, videoDefinitionPointer?.relay, + rootDefinitionPointer?.relay, ); const parentCommentId = pickString( @@ -223,17 +331,17 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { target.participantRelay, ); - const normalizedVideoEventId = + let normalizedVideoEventId = typeof videoEventId === "string" ? videoEventId.trim() : ""; - const normalizedVideoDefinitionAddress = + let normalizedVideoDefinitionAddress = typeof videoDefinitionAddress === "string" ? videoDefinitionAddress.trim() : ""; - const normalizedVideoEventRelay = + let normalizedVideoEventRelay = typeof videoEventRelay === "string" ? videoEventRelay.trim() : ""; - const normalizedVideoDefinitionRelay = + let normalizedVideoDefinitionRelay = typeof videoDefinitionRelay === "string" ? videoDefinitionRelay.trim() : ""; - const normalizedParentCommentId = + let normalizedParentCommentId = typeof parentCommentId === "string" ? parentCommentId.trim() : ""; - const normalizedParentCommentRelay = + let normalizedParentCommentRelay = typeof parentCommentRelay === "string" ? parentCommentRelay.trim() : ""; const normalizedThreadParticipantPubkey = typeof threadParticipantPubkey === "string" ? threadParticipantPubkey.trim() : ""; @@ -264,15 +372,27 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { const normalizedRootIdentifier = typeof rootIdentifier === "string" ? rootIdentifier.trim() : ""; - const normalizedRootIdentifierRelay = + let normalizedRootIdentifierRelay = typeof rootIdentifierRelay === "string" ? rootIdentifierRelay.trim() : ""; const normalizedParentIdentifier = typeof parentIdentifier === "string" ? parentIdentifier.trim() : ""; - const normalizedParentIdentifierRelay = + let normalizedParentIdentifierRelay = typeof parentIdentifierRelay === "string" ? parentIdentifierRelay.trim() : ""; + let normalizedVideoKind = pickKind( + options.videoKind, + target.videoKind, + videoEventObject?.kind, + ); + + let normalizedVideoAuthorPubkey = pickString( + options.videoAuthorPubkey, + target.videoAuthorPubkey, + videoEventObject?.pubkey, + ); + const definitionSegments = normalizedVideoDefinitionAddress ? normalizedVideoDefinitionAddress.split(":") : []; @@ -284,6 +404,7 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { target.rootKind, options.videoKind, target.videoKind, + normalizedVideoKind, derivedDefinitionKind, ); let normalizedRootKind = @@ -292,10 +413,227 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { const parentKindCandidate = pickString( options.parentKind, target.parentKind, + options.parentCommentKind, + target.parentCommentKind, ); let normalizedParentKind = typeof parentKindCandidate === "string" ? parentKindCandidate.trim() : ""; + let normalizedRootAuthorPubkey = pickString( + options.rootAuthorPubkey, + target.rootAuthorPubkey, + options.videoAuthorPubkey, + target.videoAuthorPubkey, + normalizedVideoAuthorPubkey, + derivedDefinitionPubkey, + ); + normalizedRootAuthorPubkey = + typeof normalizedRootAuthorPubkey === "string" + ? normalizedRootAuthorPubkey.trim() + : ""; + + let normalizedParentAuthorPubkey = pickString( + options.parentAuthorPubkey, + target.parentAuthorPubkey, + options.parentCommentPubkey, + target.parentCommentPubkey, + ); + normalizedParentAuthorPubkey = + typeof normalizedParentAuthorPubkey === "string" + ? normalizedParentAuthorPubkey.trim() + : ""; + + let normalizedRootAuthorRelay = pickString( + options.rootAuthorRelay, + target.rootAuthorRelay, + ); + normalizedRootAuthorRelay = + typeof normalizedRootAuthorRelay === "string" + ? normalizedRootAuthorRelay.trim() + : ""; + + let normalizedParentAuthorRelay = pickString( + options.parentAuthorRelay, + target.parentAuthorRelay, + ); + normalizedParentAuthorRelay = + typeof normalizedParentAuthorRelay === "string" + ? normalizedParentAuthorRelay.trim() + : ""; + + if (!normalizedRootIdentifier) { + const tag = findTagByName(combinedTags, "I"); + const value = getTagField(tag, 1); + if (value) { + normalizedRootIdentifier = value; + const relay = getTagField(tag, 2); + if (relay) { + normalizedRootIdentifierRelay = relay; + } + } + } else if (!normalizedRootIdentifierRelay) { + const tag = findTagByName(combinedTags, "I"); + const relay = getTagField(tag, 2); + if (relay) { + normalizedRootIdentifierRelay = relay; + } + } + + if (!normalizedVideoDefinitionAddress) { + const tag = findTagByName(combinedTags, "A", "a"); + const value = getTagField(tag, 1); + if (value) { + normalizedVideoDefinitionAddress = value; + const relay = getTagField(tag, 2); + if (relay) { + normalizedVideoDefinitionRelay = relay; + } + } + } else if (!normalizedVideoDefinitionRelay) { + const tag = findTagByName(combinedTags, "A", "a"); + const relay = getTagField(tag, 2); + if (relay) { + normalizedVideoDefinitionRelay = relay; + } + } + + const rootEventTag = findTagByName(combinedTags, "E"); + if (rootEventTag) { + const value = getTagField(rootEventTag, 1); + if (value && !normalizedVideoEventId) { + normalizedVideoEventId = value; + } + const relay = getTagField(rootEventTag, 2); + if (relay && !normalizedVideoEventRelay) { + normalizedVideoEventRelay = relay; + } + const authorHint = getTagField(rootEventTag, 3); + if (authorHint && !normalizedRootAuthorPubkey) { + normalizedRootAuthorPubkey = authorHint; + } + } + + if (!normalizedVideoEventId || !normalizedParentCommentId) { + const eventValues = []; + for (const tag of combinedTags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (typeof tag[0] === "string" && tag[0].trim().toLowerCase() === "e") { + const value = getTagField(tag, 1); + if (value) { + eventValues.push({ tag, value }); + } + } + } + if (!normalizedVideoEventId && eventValues.length) { + normalizedVideoEventId = eventValues[0].value; + const relay = getTagField(eventValues[0].tag, 2); + if (relay && !normalizedVideoEventRelay) { + normalizedVideoEventRelay = relay; + } + } + if (!normalizedParentCommentId && eventValues.length) { + for (let index = eventValues.length - 1; index >= 0; index -= 1) { + const candidate = eventValues[index]; + if ( + candidate.value && + candidate.value !== normalizedVideoEventId + ) { + normalizedParentCommentId = candidate.value; + const relay = getTagField(candidate.tag, 2); + if (relay) { + normalizedParentCommentRelay = relay; + } + const authorHint = getTagField(candidate.tag, 3); + if (authorHint && !normalizedParentAuthorPubkey) { + normalizedParentAuthorPubkey = authorHint; + } + break; + } + } + } + } + + if (normalizedParentCommentId && !normalizedParentCommentRelay) { + const parentTag = parentEventTags.find( + (tag) => + Array.isArray(tag) && + tag.length >= 2 && + typeof tag[0] === "string" && + tag[0].trim().toLowerCase() === "e" && + getTagField(tag, 1) === normalizedParentCommentId, + ); + if (parentTag) { + const relay = getTagField(parentTag, 2); + if (relay) { + normalizedParentCommentRelay = relay; + } + const authorHint = getTagField(parentTag, 3); + if (authorHint && !normalizedParentAuthorPubkey) { + normalizedParentAuthorPubkey = authorHint; + } + } + } + + if (!normalizedParentIdentifierRelay) { + const tag = findTagByName(parentEventTags, "i"); + const relay = getTagField(tag, 2); + if (relay) { + normalizedParentIdentifierRelay = relay; + } + } + + if (!normalizedRootKind) { + const tag = findTagByName(combinedTags, "K"); + const value = getTagField(tag, 1); + if (value) { + normalizedRootKind = value; + } + } + if (!normalizedVideoKind && normalizedRootKind) { + normalizedVideoKind = normalizedRootKind; + } + + if (!normalizedRootAuthorPubkey || !normalizedRootAuthorRelay) { + const tag = findTagByName(combinedTags, "P"); + if (tag) { + const value = getTagField(tag, 1); + if (value && !normalizedRootAuthorPubkey) { + normalizedRootAuthorPubkey = value; + } + const relay = getTagField(tag, 2); + if (relay && !normalizedRootAuthorRelay) { + normalizedRootAuthorRelay = relay; + } + } + } + if (!normalizedVideoAuthorPubkey && normalizedRootAuthorPubkey) { + normalizedVideoAuthorPubkey = normalizedRootAuthorPubkey; + } + + if (!normalizedParentKind) { + const tag = findTagByName(parentEventTags, "k"); + const value = getTagField(tag, 1); + if (value) { + normalizedParentKind = value; + } + } + + if (!normalizedParentAuthorPubkey || !normalizedParentAuthorRelay) { + const tag = findTagByName(parentEventTags, "p"); + if (tag) { + const value = getTagField(tag, 1); + if (value && !normalizedParentAuthorPubkey) { + normalizedParentAuthorPubkey = value; + } + const relay = getTagField(tag, 2); + if (relay && !normalizedParentAuthorRelay) { + normalizedParentAuthorRelay = relay; + } + } + } + if (!normalizedParentKind) { if (normalizedParentCommentId) { normalizedParentKind = String(COMMENT_EVENT_KIND); @@ -310,22 +648,6 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { normalizedRootKind = normalizedParentKind; } - const rootAuthorCandidate = pickString( - options.rootAuthorPubkey, - target.rootAuthorPubkey, - derivedDefinitionPubkey, - ); - let normalizedRootAuthorPubkey = - typeof rootAuthorCandidate === "string" ? rootAuthorCandidate.trim() : ""; - - const parentAuthorCandidate = pickString( - options.parentAuthorPubkey, - target.parentAuthorPubkey, - ); - let normalizedParentAuthorPubkey = - typeof parentAuthorCandidate === "string" - ? parentAuthorCandidate.trim() - : ""; if (!normalizedParentAuthorPubkey) { normalizedParentAuthorPubkey = normalizedThreadParticipantPubkey; } @@ -336,26 +658,10 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { } } - const rootAuthorRelayCandidate = pickString( - options.rootAuthorRelay, - target.rootAuthorRelay, - ); - let normalizedRootAuthorRelay = - typeof rootAuthorRelayCandidate === "string" - ? rootAuthorRelayCandidate.trim() - : ""; if (!normalizedRootAuthorRelay && normalizedVideoDefinitionRelay) { normalizedRootAuthorRelay = normalizedVideoDefinitionRelay; } - const parentAuthorRelayCandidate = pickString( - options.parentAuthorRelay, - target.parentAuthorRelay, - ); - let normalizedParentAuthorRelay = - typeof parentAuthorRelayCandidate === "string" - ? parentAuthorRelayCandidate.trim() - : ""; if (!normalizedParentAuthorRelay) { normalizedParentAuthorRelay = normalizedThreadParticipantRelay; } @@ -385,6 +691,8 @@ function normalizeCommentTarget(targetInput = {}, overrides = {}) { videoEventRelay: normalizedVideoEventRelay, videoDefinitionAddress: normalizedVideoDefinitionAddress, videoDefinitionRelay: normalizedVideoDefinitionRelay, + videoKind: normalizedVideoKind, + videoAuthorPubkey: normalizedVideoAuthorPubkey, parentCommentId: normalizedParentCommentId, parentCommentRelay: normalizedParentCommentRelay, threadParticipantPubkey: normalizedThreadParticipantPubkey, diff --git a/js/services/commentThreadService.js b/js/services/commentThreadService.js index 1e2ae21c..06027413 100644 --- a/js/services/commentThreadService.js +++ b/js/services/commentThreadService.js @@ -2,6 +2,7 @@ import { devLogger } from "../utils/logger.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; +import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; const ROOT_PARENT_KEY = "__root__"; const DEFAULT_INITIAL_LIMIT = 40; @@ -24,6 +25,16 @@ function normalizePubkey(pubkey) { return normalized ? normalized.toLowerCase() : ""; } +function normalizeKind(value) { + if (Number.isFinite(value)) { + return String(Math.floor(value)); + } + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + return ""; +} + function toParentKey(parentId) { return parentId ? parentId : ROOT_PARENT_KEY; } @@ -112,7 +123,11 @@ export default class CommentThreadService { this.subscriptionCleanup = null; this.videoEventId = ""; this.videoAddressPointer = ""; + this.videoKind = ""; + this.videoAuthorPubkey = ""; this.parentCommentId = ""; + this.parentCommentKind = ""; + this.parentCommentPubkey = ""; this.activeRelays = null; } @@ -138,7 +153,11 @@ export default class CommentThreadService { this.videoEventId = normalizeString(video?.id); this.videoAddressPointer = buildVideoAddressPointer(video); + this.videoKind = normalizeKind(video?.kind); + this.videoAuthorPubkey = normalizePubkey(video?.pubkey); this.parentCommentId = normalizeString(parentCommentId); + this.parentCommentKind = this.parentCommentId ? String(COMMENT_EVENT_KIND) : ""; + this.parentCommentPubkey = ""; this.activeRelays = Array.isArray(relays) ? [...relays] : null; if (!this.videoEventId) { @@ -164,6 +183,20 @@ export default class CommentThreadService { if (this.videoAddressPointer) { target.videoDefinitionAddress = this.videoAddressPointer; } + if (this.videoKind) { + target.videoKind = this.videoKind; + } + if (this.videoAuthorPubkey) { + target.videoAuthorPubkey = this.videoAuthorPubkey; + } + if (this.parentCommentId) { + if (this.parentCommentKind) { + target.parentCommentKind = this.parentCommentKind; + } + if (this.parentCommentPubkey) { + target.parentCommentPubkey = this.parentCommentPubkey; + } + } let events = []; try { @@ -255,6 +288,15 @@ export default class CommentThreadService { const parentKey = toParentKey(parentId); const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; const pubkey = normalizePubkey(event.pubkey); + if (this.parentCommentId && eventId === this.parentCommentId) { + const kindValue = normalizeKind(event.kind); + if (kindValue) { + this.parentCommentKind = kindValue; + } + if (pubkey) { + this.parentCommentPubkey = pubkey; + } + } const existingMeta = this.metaById.get(eventId); this.eventsById.set(eventId, event); @@ -458,6 +500,11 @@ export default class CommentThreadService { const payload = { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId || null, + videoDefinitionAddress: this.videoAddressPointer || null, + videoKind: this.videoKind || null, + videoAuthorPubkey: this.videoAuthorPubkey || null, + parentCommentKind: this.parentCommentKind || null, + parentCommentPubkey: this.parentCommentPubkey || null, topLevelIds: this.getCommentIdsForParent(null), commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), @@ -470,6 +517,11 @@ export default class CommentThreadService { const payload = { videoEventId: this.videoEventId, parentCommentId: parentId || null, + videoDefinitionAddress: this.videoAddressPointer || null, + videoKind: this.videoKind || null, + videoAuthorPubkey: this.videoAuthorPubkey || null, + parentCommentKind: this.parentCommentKind || null, + parentCommentPubkey: this.parentCommentPubkey || null, commentIds: [eventId], commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), @@ -489,6 +541,11 @@ export default class CommentThreadService { return { videoEventId: this.videoEventId, parentCommentId: this.parentCommentId || null, + videoDefinitionAddress: this.videoAddressPointer || null, + videoKind: this.videoKind || null, + videoAuthorPubkey: this.videoAuthorPubkey || null, + parentCommentKind: this.parentCommentKind || null, + parentCommentPubkey: this.parentCommentPubkey || null, commentsById: this.cloneCommentsMap(), childrenByParent: this.cloneTreeMap(), profiles: this.getProfilesSnapshot(), @@ -536,6 +593,14 @@ export default class CommentThreadService { return Array.isArray(list) ? [...list] : []; } + getCommentEvent(commentId) { + const normalized = normalizeString(commentId); + if (!normalized) { + return null; + } + return this.eventsById.get(normalized) || null; + } + waitForProfileHydration() { return this.profileHydrationPromise || Promise.resolve(); } diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index 4f156a76..bc05c455 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -1,5 +1,6 @@ import { devLogger } from "../utils/logger.js"; import { buildVideoAddressPointer } from "../utils/videoPointer.js"; +import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js"; const DEFAULT_LIMIT = 40; @@ -7,6 +8,16 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeKind(value) { + if (Number.isFinite(value)) { + return String(Math.floor(value)); + } + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + return ""; +} + export default class VideoModalCommentController { constructor({ commentThreadService = null, @@ -73,7 +84,11 @@ export default class VideoModalCommentController { this.modalCommentState = { videoEventId: null, videoDefinitionAddress: null, + videoKind: null, + videoAuthorPubkey: null, parentCommentId: null, + parentCommentKind: null, + parentCommentPubkey: null, }; this.modalCommentProfiles = new Map(); this.modalCommentLimit = this.commentThreadService?.defaultLimit || DEFAULT_LIMIT; @@ -131,6 +146,8 @@ export default class VideoModalCommentController { const videoEventId = normalizeString(video.id); const videoDefinitionAddress = buildVideoAddressPointer(video); + const videoKind = normalizeKind(video.kind); + const videoAuthorPubkey = normalizeString(video.pubkey); if (!videoEventId) { this.resetModalCommentState({ hide: false }); @@ -143,7 +160,11 @@ export default class VideoModalCommentController { this.modalCommentState = { videoEventId, videoDefinitionAddress: videoDefinitionAddress || null, + videoKind: videoKind || null, + videoAuthorPubkey: videoAuthorPubkey || null, parentCommentId: null, + parentCommentKind: null, + parentCommentPubkey: null, }; this.modalCommentProfiles = new Map(); if (this.commentThreadService?.defaultLimit) { @@ -221,7 +242,11 @@ export default class VideoModalCommentController { this.modalCommentState = { videoEventId: null, videoDefinitionAddress: null, + videoKind: null, + videoAuthorPubkey: null, parentCommentId: null, + parentCommentKind: null, + parentCommentPubkey: null, }; this.modalCommentProfiles = new Map(); if (resetUi) { @@ -311,6 +336,28 @@ export default class VideoModalCommentController { this.modalCommentState.parentCommentId = normalizeString( snapshot.parentCommentId, ) || null; + if (typeof snapshot.videoDefinitionAddress === "string") { + const pointer = normalizeString(snapshot.videoDefinitionAddress); + if (pointer) { + this.modalCommentState.videoDefinitionAddress = pointer; + } + } + const snapshotVideoKind = normalizeKind(snapshot.videoKind); + if (snapshotVideoKind) { + this.modalCommentState.videoKind = snapshotVideoKind; + } + const snapshotVideoAuthor = normalizeString(snapshot.videoAuthorPubkey); + if (snapshotVideoAuthor) { + this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor; + } + const snapshotParentKind = normalizeKind(snapshot.parentCommentKind); + if (snapshotParentKind) { + this.modalCommentState.parentCommentKind = snapshotParentKind; + } + const snapshotParentPubkey = normalizeString(snapshot.parentCommentPubkey); + if (snapshotParentPubkey) { + this.modalCommentState.parentCommentPubkey = snapshotParentPubkey; + } const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot); this.videoModal.renderComments?.(sanitizedSnapshot); @@ -346,6 +393,19 @@ export default class VideoModalCommentController { return; } const enriched = this.enrichCommentEvent(event); + if ( + this.modalCommentState.parentCommentId && + commentId === this.modalCommentState.parentCommentId + ) { + const eventKind = normalizeKind(event.kind); + if (eventKind) { + this.modalCommentState.parentCommentKind = eventKind; + } + const eventPubkey = normalizeString(event.pubkey); + if (eventPubkey) { + this.modalCommentState.parentCommentPubkey = eventPubkey; + } + } this.videoModal.appendComment?.(enriched); }); } @@ -486,6 +546,10 @@ export default class VideoModalCommentController { const videoEventId = normalizeString(video.id); const videoDefinitionAddress = buildVideoAddressPointer(video); + const videoKind = + this.modalCommentState.videoKind || normalizeKind(video.kind); + const videoAuthorPubkey = + this.modalCommentState.videoAuthorPubkey || normalizeString(video.pubkey); if (!videoEventId) { this.callbacks.showError("Comments are unavailable for this video."); @@ -493,6 +557,34 @@ export default class VideoModalCommentController { } const parentCommentId = normalizeString(detail.parentId) || null; + let parentCommentKind = this.modalCommentState.parentCommentKind + ? normalizeKind(this.modalCommentState.parentCommentKind) + : ""; + let parentCommentPubkey = this.modalCommentState.parentCommentPubkey + ? normalizeString(this.modalCommentState.parentCommentPubkey) + : ""; + + if (parentCommentId && typeof this.commentThreadService?.getCommentEvent === "function") { + const parentEvent = this.commentThreadService.getCommentEvent( + parentCommentId, + ); + if (parentEvent) { + const eventKind = normalizeKind(parentEvent.kind); + if (eventKind) { + parentCommentKind = eventKind; + this.modalCommentState.parentCommentKind = eventKind; + } + const eventPubkey = normalizeString(parentEvent.pubkey); + if (eventPubkey) { + parentCommentPubkey = eventPubkey; + this.modalCommentState.parentCommentPubkey = eventPubkey; + } + } + } + + if (!parentCommentKind && parentCommentId) { + parentCommentKind = String(COMMENT_EVENT_KIND); + } this.videoModal.setCommentComposerState?.({ disabled: true, @@ -507,6 +599,21 @@ export default class VideoModalCommentController { if (videoDefinitionAddress) { publishPayload.videoDefinitionAddress = videoDefinitionAddress; } + if (videoKind) { + publishPayload.videoKind = videoKind; + publishPayload.rootKind = videoKind; + } + if (videoAuthorPubkey) { + publishPayload.videoAuthorPubkey = videoAuthorPubkey; + publishPayload.rootAuthorPubkey = videoAuthorPubkey; + } + if (parentCommentKind) { + publishPayload.parentCommentKind = parentCommentKind; + } + if (parentCommentPubkey) { + publishPayload.parentCommentPubkey = parentCommentPubkey; + publishPayload.parentAuthorPubkey = parentCommentPubkey; + } const publishPromise = Promise.resolve( this.services.publishComment( diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index ac186682..23f3c85d 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -113,6 +113,8 @@ test( assert.deepStrictEqual(lastFetchTarget, { videoEventId: "video123", videoDefinitionAddress: "30078:authorpk:video-root", + videoKind: "30078", + videoAuthorPubkey: "authorpk", parentCommentId: "", }); assert.deepStrictEqual(lastFetchOptions, { @@ -129,6 +131,11 @@ test( const initialSnapshot = initialPayloads[0]; assert.ok(initialSnapshot.childrenByParent instanceof Map); + assert.equal(initialSnapshot.videoDefinitionAddress, "30078:authorpk:video-root"); + assert.equal(initialSnapshot.videoKind, "30078"); + assert.equal(initialSnapshot.videoAuthorPubkey, "authorpk"); + assert.equal(initialSnapshot.parentCommentKind, null); + assert.equal(initialSnapshot.parentCommentPubkey, null); assert.deepStrictEqual(initialSnapshot.topLevelIds, [ "comment-2", "comment-1", @@ -241,6 +248,8 @@ test( assert.equal(fetchTargets.length, 1, "fetch should run even without an address"); assert.deepStrictEqual(fetchTargets[0], { videoEventId: "video123", + videoKind: "30078", + videoAuthorPubkey: "authorpk", parentCommentId: "", }); assert.equal( @@ -268,6 +277,9 @@ test( assert.equal(errors.length, 0, "missing address should not emit errors"); assert.equal(snapshot.videoEventId, "video123"); + assert.equal(snapshot.videoKind, "30078"); + assert.equal(snapshot.videoAuthorPubkey, "authorpk"); + assert.equal(snapshot.videoDefinitionAddress, null); }, ); diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs index b577ba6e..c6a99474 100644 --- a/tests/nostr/comment-events.test.mjs +++ b/tests/nostr/comment-events.test.mjs @@ -230,6 +230,50 @@ test("publishComment accepts legacy targets with only an event id", async () => ); }); +test("publishComment derives root and parent metadata from parent comment tags", async () => { + const { client } = createMockClient(); + + const parentEvent = { + id: "parent-comment", + kind: COMMENT_EVENT_KIND, + pubkey: "parentpk", + tags: [ + ["E", "video-event", "wss://root", "videopk"], + ["K", "30078"], + ["P", "videopk", "wss://author"], + ["A", "30078:videopk:root", "wss://definition"], + ["e", "video-event", "wss://root"], + ["e", "parent-comment", "wss://parent", "parentpk"], + ["k", String(COMMENT_EVENT_KIND)], + ["p", "parentpk", "wss://parent"], + ], + }; + + const result = await publishComment( + client, + { parentComment: parentEvent }, + { content: "Reply" }, + { + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => ({ ...event, id: "signed" }), + DEFAULT_NIP07_PERMISSION_METHODS: [], + }, + ); + + assert.equal(result.ok, true, "publish should succeed when deriving metadata"); + const tags = buildCommentEventTags(result.event); + assert.deepEqual(tags, [ + ["A", "30078:videopk:root", "wss://definition"], + ["K", "30078"], + ["P", "videopk", "wss://author"], + ["a", "30078:videopk:root", "wss://definition"], + ["e", "parent-comment", "wss://parent", "parentpk"], + ["k", String(COMMENT_EVENT_KIND)], + ["p", "parentpk", "wss://parent"], + ]); +}); + test("publishComment emits only video event #e tag when address and parent are absent", async () => { const { client, publishCalls } = createMockClient(); diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 6135a492..e09659bd 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { setupModal } from "./video-modal-accessibility.test.mjs"; import VideoModalCommentController from "../js/ui/videoModalCommentController.js"; +import { COMMENT_EVENT_KIND } from "../js/nostr/commentEvents.js"; const FOCUSABLE_SELECTOR = [ "a[href]", @@ -247,6 +248,10 @@ test( assert.deepStrictEqual(payload, { videoEventId: "legacy-video", parentCommentId: null, + videoKind: "30078", + videoAuthorPubkey: "AUTHORPK", + rootKind: "30078", + rootAuthorPubkey: "AUTHORPK", }); assert.equal( "videoDefinitionAddress" in payload, @@ -270,6 +275,78 @@ test( }, ); +test( + "VideoModalCommentController includes parent metadata when replying", + async () => { + const publishCalls = []; + + const parentEvent = { + id: "parent-1", + kind: COMMENT_EVENT_KIND, + pubkey: "parentpk", + tags: [], + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + setCallbacks: () => {}, + getCommentEvent: (id) => (id === "parent-1" ? parentEvent : null), + processIncomingEvent: () => {}, + }, + videoModal: { + setCommentComposerState: () => {}, + setCommentStatus: () => {}, + resetCommentComposer: () => {}, + appendComment: () => {}, + }, + auth: { + isLoggedIn: () => true, + }, + services: { + publishComment: async (payload) => { + publishCalls.push(payload); + return { ok: true, event: { id: "reply", tags: [] } }; + }, + }, + utils: { + normalizeHexPubkey: (value) => value?.toLowerCase?.() || value, + }, + }); + + controller.currentVideo = { + id: "video123", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + }; + controller.modalCommentState = { + videoEventId: "video123", + videoDefinitionAddress: null, + videoKind: "30078", + videoAuthorPubkey: "AUTHORPK", + parentCommentId: "parent-1", + parentCommentKind: String(COMMENT_EVENT_KIND), + parentCommentPubkey: null, + }; + + await controller.handleVideoModalCommentSubmit({ + text: "Replying", + parentId: "parent-1", + }); + + assert.equal(publishCalls.length, 1, "reply should trigger publish call"); + const payload = publishCalls[0]; + assert.equal(payload.videoEventId, "video123"); + assert.equal(payload.videoKind, "30078"); + 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"); + }, +); + test("VideoModal comment section exposes aria landmarks and participates in focus trap", async (t) => { const { window, document, modal, playerModal, trigger, cleanup } = await setupModal(); t.after(cleanup);