diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index a9bb2877..f94e3722 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -6,7 +6,6 @@ import { } from "../nostrEventSchemas.js"; import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; -import { normalizePointerInput } from "./watchHistory.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { LRUCache } from "../utils/lruCache.js"; import { CACHE_POLICIES } from "./cachePolicies.js"; @@ -16,6 +15,13 @@ import { getActiveSigner } from "../nostrClientRegistry.js"; import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js"; import { pMap } from "../utils/asyncUtils.js"; import { RELAY_BACKGROUND_CONCURRENCY } from "./relayConstants.js"; +import { + normalizeCommentTarget, + normalizeDescriptorString, + getAllowedCommentKinds, + normalizeTagValue, + isVideoCommentEvent, +} from "./commentTargetNormalizer.js"; const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT]; @@ -25,15 +31,7 @@ export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind) ? COMMENT_EVENT_SCHEMA.kind : 1111; export const LEGACY_COMMENT_KIND = 1; -const ALLOWED_COMMENT_KINDS = Object.freeze( - COMMENT_EVENT_KIND === LEGACY_COMMENT_KIND - ? [COMMENT_EVENT_KIND] - : [COMMENT_EVENT_KIND, LEGACY_COMMENT_KIND] -); -function getAllowedCommentKinds() { - return ALLOWED_COMMENT_KINDS.slice(); -} function sanitizeRelayList(primary, fallback) { const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []); if (primaryList.length) { @@ -46,807 +44,6 @@ function sanitizeRelayList(primary, fallback) { return sanitizeRelayUrls(RELAY_URLS); } -function normalizeRelay(candidate) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - if (Array.isArray(candidate)) { - const relayCandidate = candidate[2]; - if (typeof relayCandidate === "string" && relayCandidate.trim()) { - return relayCandidate.trim(); - } - } - if (candidate && typeof candidate === "object") { - if (typeof candidate.relay === "string" && candidate.relay.trim()) { - return candidate.relay.trim(); - } - if (typeof candidate.url === "string" && candidate.url.trim()) { - return candidate.url.trim(); - } - if (Array.isArray(candidate.relays)) { - for (const relayCandidate of candidate.relays) { - if (typeof relayCandidate === "string" && relayCandidate.trim()) { - return relayCandidate.trim(); - } - } - } - if (candidate.tag) { - return normalizeRelay(candidate.tag); - } - if (candidate.pointer) { - return normalizeRelay(candidate.pointer); - } - } - return ""; -} - -function normalizePointerCandidate(candidate, expectedType) { - if (!candidate) { - return null; - } - - if (Array.isArray(candidate)) { - const tagName = - typeof candidate[0] === "string" ? candidate[0].trim().toLowerCase() : ""; - if ( - tagName === expectedType && - typeof candidate[1] === "string" && - candidate[1].trim() - ) { - return { - value: candidate[1].trim(), - relay: normalizeRelay(candidate), - }; - } - return null; - } - - if (typeof candidate === "string") { - const pointer = normalizePointerInput(candidate); - if (pointer?.type === expectedType && pointer.value) { - return { - value: pointer.value.trim(), - relay: normalizeRelay(pointer), - }; - } - if (expectedType === "e" && candidate.trim() && !candidate.includes(":")) { - return { value: candidate.trim(), relay: "" }; - } - if (expectedType === "a" && candidate.trim() && candidate.includes(":")) { - return { value: candidate.trim(), relay: "" }; - } - return null; - } - - if (candidate && typeof candidate === "object") { - if ( - typeof candidate.type === "string" && - candidate.type.toLowerCase() === expectedType && - typeof candidate.value === "string" && - candidate.value.trim() - ) { - return { - value: candidate.value.trim(), - relay: normalizeRelay(candidate), - }; - } - if ( - expectedType === "e" && - typeof candidate.id === "string" && - candidate.id.trim() - ) { - return { - value: candidate.id.trim(), - relay: normalizeRelay(candidate), - }; - } - if ( - expectedType === "a" && - typeof candidate.address === "string" && - candidate.address.trim() - ) { - return { - value: candidate.address.trim(), - relay: normalizeRelay(candidate), - }; - } - if (candidate.tag) { - return normalizePointerCandidate(candidate.tag, expectedType); - } - if (candidate.pointer) { - return normalizePointerCandidate(candidate.pointer, expectedType); - } - } - - return null; -} - -function normalizeTagName(name) { - return typeof name === "string" ? name.trim().toLowerCase() : ""; -} - -function normalizeTagValue(value) { - if (value === null || value === undefined) { - return ""; - } - if (Number.isFinite(value)) { - return String(Math.floor(value)); - } - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed.toLowerCase() : ""; - } - return ""; -} - -function normalizeDescriptorString(value) { - if (value === null || value === undefined) { - return ""; - } - if (Number.isFinite(value)) { - return String(Math.floor(value)); - } - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? trimmed.toLowerCase() : ""; - } - if (typeof value === "object" && typeof value.value !== "undefined") { - return normalizeDescriptorString(value.value); - } - return ""; -} - -function normalizeDescriptorRelay(value) { - if (typeof value === "string") { - return value.trim(); - } - return ""; -} - - -function pickString(...candidates) { - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - } - 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) => normalizeTagName(name)) - .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(normalizeTagName(name))) { - return tag; - } - } - return null; -} - -class CommentTargetNormalizer { - constructor(targetInput, overrides) { - this.target = targetInput && typeof targetInput === "object" ? targetInput : {}; - this.options = overrides && typeof overrides === "object" ? overrides : {}; - - // Pointers - this.videoEventPointer = null; - this.videoDefinitionPointer = null; - this.rootDefinitionPointer = null; - this.rootEventPointer = null; - this.parentCommentPointer = null; - - // Events - this.videoEventObject = null; - this.parentCommentEvent = null; - - // Tags - this.rootEventTags = []; - this.parentEventTags = []; - this.combinedTags = []; - - // Normalized Values - this.normalizedVideoEventId = ""; - this.normalizedVideoEventRelay = ""; - this.normalizedVideoDefinitionAddress = ""; - this.normalizedVideoDefinitionRelay = ""; - this.normalizedParentCommentId = ""; - this.normalizedParentCommentRelay = ""; - this.normalizedThreadParticipantPubkey = ""; - this.normalizedThreadParticipantRelay = ""; - this.normalizedRootIdentifier = ""; - this.normalizedRootIdentifierRelay = ""; - this.normalizedParentIdentifier = ""; - this.normalizedParentIdentifierRelay = ""; - this.normalizedVideoKind = ""; - this.normalizedVideoAuthorPubkey = ""; - this.derivedDefinitionKind = ""; - this.derivedDefinitionPubkey = ""; - this.normalizedRootKind = ""; - this.normalizedParentKind = ""; - this.normalizedRootAuthorPubkey = ""; - this.normalizedParentAuthorPubkey = ""; - this.normalizedRootAuthorRelay = ""; - this.normalizedParentAuthorRelay = ""; - } - - normalize() { - this.extractPointers(); - this.resolveEventsAndTags(); - this.extractInitialValues(); - this.enrichFromTags(); - this.applyDefaults(); - return this.getDescriptor(); - } - - extractPointers() { - const { target, options } = this; - this.videoEventPointer = - normalizePointerCandidate(options.videoEventPointer, "e") || - normalizePointerCandidate(options.videoEvent, "e") || - normalizePointerCandidate(target.videoEventPointer, "e") || - normalizePointerCandidate(target.videoEvent, "e") || - normalizePointerCandidate(target.eventPointer, "e") || - normalizePointerCandidate(target.event, "e"); - - this.videoDefinitionPointer = - normalizePointerCandidate(options.videoDefinitionPointer, "a") || - normalizePointerCandidate(options.videoDefinition, "a") || - normalizePointerCandidate(target.videoDefinitionPointer, "a") || - normalizePointerCandidate(target.videoDefinition, "a") || - normalizePointerCandidate(target.definitionPointer, "a") || - normalizePointerCandidate(target.definition, "a"); - - this.rootDefinitionPointer = - normalizePointerCandidate(options.rootDefinitionPointer, "a") || - normalizePointerCandidate(options.rootPointer, "a") || - normalizePointerCandidate(target.rootDefinitionPointer, "a") || - normalizePointerCandidate(target.rootPointer, "a"); - - this.rootEventPointer = - normalizePointerCandidate(options.rootEventPointer, "e") || - normalizePointerCandidate(options.rootPointer, "e") || - normalizePointerCandidate(target.rootEventPointer, "e") || - normalizePointerCandidate(target.rootPointer, "e"); - - this.parentCommentPointer = - normalizePointerCandidate(options.parentCommentPointer, "e") || - normalizePointerCandidate(options.parentComment, "e") || - normalizePointerCandidate(target.parentCommentPointer, "e") || - normalizePointerCandidate(target.parentComment, "e") || - normalizePointerCandidate(target.parentPointer, "e"); - } - - resolveEventsAndTags() { - const { target, options } = this; - this.videoEventObject = resolveEventCandidate( - options.videoEvent, - options.rootEvent, - target.videoEvent, - target.rootEvent, - ); - - this.parentCommentEvent = resolveEventCandidate( - options.parentCommentEvent, - options.parentComment, - target.parentCommentEvent, - target.parentComment, - ); - - this.rootEventTags = collectTagsFromEvent(this.videoEventObject); - this.parentEventTags = collectTagsFromEvent(this.parentCommentEvent); - this.combinedTags = [...this.parentEventTags, ...this.rootEventTags]; - } - - extractInitialValues() { - const { target, options } = this; - - const normalize = (val) => (typeof val === "string" ? val.trim() : ""); - - this.normalizedVideoEventId = normalize(pickString( - options.videoEventId, - target.videoEventId, - target.eventId, - this.videoEventPointer?.value, - this.rootEventPointer?.value, - )); - - this.normalizedVideoEventRelay = normalize(pickString( - options.videoEventRelay, - target.videoEventRelay, - target.eventRelay, - this.videoEventPointer?.relay, - this.rootEventPointer?.relay, - )); - - this.normalizedVideoDefinitionAddress = normalize(pickString( - options.videoDefinitionAddress, - target.videoDefinitionAddress, - target.definitionAddress, - this.videoDefinitionPointer?.value, - this.rootDefinitionPointer?.value, - )); - - this.normalizedVideoDefinitionRelay = normalize(pickString( - options.videoDefinitionRelay, - target.videoDefinitionRelay, - target.definitionRelay, - this.videoDefinitionPointer?.relay, - this.rootDefinitionPointer?.relay, - )); - - this.normalizedParentCommentId = normalize(pickString( - options.parentCommentId, - target.parentCommentId, - target.parentId, - this.parentCommentPointer?.value, - )); - - this.normalizedParentCommentRelay = normalize(pickString( - options.parentCommentRelay, - target.parentCommentRelay, - target.parentRelay, - this.parentCommentPointer?.relay, - )); - - this.normalizedThreadParticipantPubkey = normalize(pickString( - options.threadParticipantPubkey, - target.threadParticipantPubkey, - target.participantPubkey, - target.authorPubkey, - )); - - this.normalizedThreadParticipantRelay = normalize(pickString( - options.threadParticipantRelay, - target.threadParticipantRelay, - target.participantRelay, - )); - - this.normalizedRootIdentifier = normalize(pickString( - options.rootIdentifier, - target.rootIdentifier, - options.rootPointer, - target.rootPointer, - )); - - this.normalizedRootIdentifierRelay = normalize(pickString( - options.rootIdentifierRelay, - target.rootIdentifierRelay, - )); - - this.normalizedParentIdentifier = normalize(pickString( - options.parentIdentifier, - target.parentIdentifier, - )); - - this.normalizedParentIdentifierRelay = normalize(pickString( - options.parentIdentifierRelay, - target.parentIdentifierRelay, - )); - - this.normalizedVideoKind = pickKind( - options.videoKind, - target.videoKind, - this.videoEventObject?.kind, - ); - - this.normalizedVideoAuthorPubkey = pickString( - options.videoAuthorPubkey, - target.videoAuthorPubkey, - this.videoEventObject?.pubkey, - ); - - const definitionSegments = this.normalizedVideoDefinitionAddress - ? this.normalizedVideoDefinitionAddress.split(":") - : []; - this.derivedDefinitionKind = definitionSegments[0] || ""; - this.derivedDefinitionPubkey = definitionSegments[1] || ""; - - const rootKindCandidate = pickString( - options.rootKind, - target.rootKind, - options.videoKind, - target.videoKind, - this.normalizedVideoKind, - this.derivedDefinitionKind, - ); - this.normalizedRootKind = - typeof rootKindCandidate === "string" ? rootKindCandidate.trim() : ""; - - const parentKindCandidate = pickString( - options.parentKind, - target.parentKind, - options.parentCommentKind, - target.parentCommentKind, - pickKind(this.parentCommentEvent?.kind), - ); - this.normalizedParentKind = - typeof parentKindCandidate === "string" ? parentKindCandidate.trim() : ""; - - let normalizedRootAuthorPubkey = pickString( - options.rootAuthorPubkey, - target.rootAuthorPubkey, - options.videoAuthorPubkey, - target.videoAuthorPubkey, - this.normalizedVideoAuthorPubkey, - this.derivedDefinitionPubkey, - ); - this.normalizedRootAuthorPubkey = - typeof normalizedRootAuthorPubkey === "string" - ? normalizedRootAuthorPubkey.trim() - : ""; - - let normalizedParentAuthorPubkey = pickString( - options.parentAuthorPubkey, - target.parentAuthorPubkey, - options.parentCommentPubkey, - target.parentCommentPubkey, - this.parentCommentEvent?.pubkey, - ); - this.normalizedParentAuthorPubkey = - typeof normalizedParentAuthorPubkey === "string" - ? normalizedParentAuthorPubkey.trim() - : ""; - - let normalizedRootAuthorRelay = pickString( - options.rootAuthorRelay, - target.rootAuthorRelay, - ); - this.normalizedRootAuthorRelay = - typeof normalizedRootAuthorRelay === "string" - ? normalizedRootAuthorRelay.trim() - : ""; - - let normalizedParentAuthorRelay = pickString( - options.parentAuthorRelay, - target.parentAuthorRelay, - ); - this.normalizedParentAuthorRelay = - typeof normalizedParentAuthorRelay === "string" - ? normalizedParentAuthorRelay.trim() - : ""; - } - - enrichFromTags() { - const getTagField = (tag, index) => - Array.isArray(tag) && typeof tag[index] === "string" - ? tag[index].trim() - : ""; - - if (!this.normalizedRootIdentifier) { - const tag = findTagByName(this.combinedTags, "I"); - const value = getTagField(tag, 1); - if (value) { - this.normalizedRootIdentifier = value; - const relay = getTagField(tag, 2); - if (relay) { - this.normalizedRootIdentifierRelay = relay; - } - } - } else if (!this.normalizedRootIdentifierRelay) { - const tag = findTagByName(this.combinedTags, "I"); - const relay = getTagField(tag, 2); - if (relay) { - this.normalizedRootIdentifierRelay = relay; - } - } - - if (!this.normalizedVideoDefinitionAddress) { - const tag = findTagByName(this.combinedTags, "A", "a"); - const value = getTagField(tag, 1); - if (value) { - this.normalizedVideoDefinitionAddress = value; - const relay = getTagField(tag, 2); - if (relay) { - this.normalizedVideoDefinitionRelay = relay; - } - } - } else if (!this.normalizedVideoDefinitionRelay) { - const tag = findTagByName(this.combinedTags, "A", "a"); - const relay = getTagField(tag, 2); - if (relay) { - this.normalizedVideoDefinitionRelay = relay; - } - } - - const rootEventTag = findTagByName(this.combinedTags, "E"); - if (rootEventTag) { - const value = getTagField(rootEventTag, 1); - if (value && !this.normalizedVideoEventId) { - this.normalizedVideoEventId = value; - } - const relay = getTagField(rootEventTag, 2); - if (relay && !this.normalizedVideoEventRelay) { - this.normalizedVideoEventRelay = relay; - } - const authorHint = getTagField(rootEventTag, 3); - if (authorHint && !this.normalizedRootAuthorPubkey) { - this.normalizedRootAuthorPubkey = authorHint; - } - } - - if (!this.normalizedVideoEventId || !this.normalizedParentCommentId) { - const eventValues = []; - for (const tag of this.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 (!this.normalizedVideoEventId && eventValues.length) { - this.normalizedVideoEventId = eventValues[0].value; - const relay = getTagField(eventValues[0].tag, 2); - if (relay && !this.normalizedVideoEventRelay) { - this.normalizedVideoEventRelay = relay; - } - } - if (!this.normalizedParentCommentId && eventValues.length) { - for (let index = eventValues.length - 1; index >= 0; index -= 1) { - const candidate = eventValues[index]; - if ( - candidate.value && - candidate.value !== this.normalizedVideoEventId - ) { - this.normalizedParentCommentId = candidate.value; - const relay = getTagField(candidate.tag, 2); - if (relay) { - this.normalizedParentCommentRelay = relay; - } - const authorHint = getTagField(candidate.tag, 3); - if (authorHint && !this.normalizedParentAuthorPubkey) { - this.normalizedParentAuthorPubkey = authorHint; - } - break; - } - } - } - } - - if (this.normalizedParentCommentId && !this.normalizedParentCommentRelay) { - const parentTag = this.parentEventTags.find( - (tag) => - Array.isArray(tag) && - tag.length >= 2 && - typeof tag[0] === "string" && - tag[0].trim().toLowerCase() === "e" && - getTagField(tag, 1) === this.normalizedParentCommentId, - ); - if (parentTag) { - const relay = getTagField(parentTag, 2); - if (relay) { - this.normalizedParentCommentRelay = relay; - } - const authorHint = getTagField(parentTag, 3); - if (authorHint && !this.normalizedParentAuthorPubkey) { - this.normalizedParentAuthorPubkey = authorHint; - } - } - } - - if (!this.normalizedParentIdentifierRelay) { - const tag = findTagByName(this.parentEventTags, "i"); - const relay = getTagField(tag, 2); - if (relay) { - this.normalizedParentIdentifierRelay = relay; - } - } - - if (!this.normalizedRootKind) { - const tag = findTagByName(this.combinedTags, "K"); - const value = getTagField(tag, 1); - if (value) { - this.normalizedRootKind = value; - } - } - if (!this.normalizedVideoKind && this.normalizedRootKind) { - this.normalizedVideoKind = this.normalizedRootKind; - } - - if (!this.normalizedRootAuthorPubkey || !this.normalizedRootAuthorRelay) { - const tag = findTagByName(this.combinedTags, "P"); - if (tag) { - const value = getTagField(tag, 1); - if (value && !this.normalizedRootAuthorPubkey) { - this.normalizedRootAuthorPubkey = value; - } - const relay = getTagField(tag, 2); - if (relay && !this.normalizedRootAuthorRelay) { - this.normalizedRootAuthorRelay = relay; - } - } - } - if (!this.normalizedVideoAuthorPubkey && this.normalizedRootAuthorPubkey) { - this.normalizedVideoAuthorPubkey = this.normalizedRootAuthorPubkey; - } - - if (!this.normalizedParentKind) { - const tag = findTagByName(this.parentEventTags, "k"); - const value = getTagField(tag, 1); - if (value) { - this.normalizedParentKind = value; - } - } - - if (!this.normalizedParentAuthorRelay && this.normalizedParentAuthorPubkey) { - const parentTag = this.parentEventTags.find( - (tag) => - Array.isArray(tag) && - tag.length >= 2 && - typeof tag[0] === "string" && - tag[0].trim().toLowerCase() === "p" && - normalizeTagValue(tag[1]) === normalizeTagValue(this.normalizedParentAuthorPubkey), - ); - if (parentTag) { - const relay = getTagField(parentTag, 2); - if (relay) { - this.normalizedParentAuthorRelay = relay; - } - } - } - - if (!this.normalizedParentAuthorPubkey || !this.normalizedParentAuthorRelay) { - const tag = findTagByName(this.parentEventTags, "p"); - if (tag) { - const value = getTagField(tag, 1); - if (value && !this.normalizedParentAuthorPubkey) { - this.normalizedParentAuthorPubkey = value; - } - const relay = getTagField(tag, 2); - if (relay && !this.normalizedParentAuthorRelay) { - this.normalizedParentAuthorRelay = relay; - } - } - } - } - - applyDefaults() { - if (!this.normalizedParentKind) { - if (this.normalizedParentCommentId) { - this.normalizedParentKind = String(COMMENT_EVENT_KIND); - } else if (this.normalizedRootKind) { - this.normalizedParentKind = this.normalizedRootKind; - } else if (this.derivedDefinitionKind) { - this.normalizedParentKind = this.derivedDefinitionKind; - } - } - - if (!this.normalizedRootKind) { - this.normalizedRootKind = this.normalizedParentKind; - } - - if (!this.normalizedParentAuthorPubkey) { - this.normalizedParentAuthorPubkey = this.normalizedThreadParticipantPubkey; - } - - if (!this.normalizedRootAuthorPubkey) { - if (!this.normalizedParentCommentId && this.normalizedParentAuthorPubkey) { - this.normalizedRootAuthorPubkey = this.normalizedParentAuthorPubkey; - } - } - - if (!this.normalizedRootAuthorRelay && this.normalizedVideoDefinitionRelay) { - this.normalizedRootAuthorRelay = this.normalizedVideoDefinitionRelay; - } - - if (!this.normalizedParentAuthorRelay) { - this.normalizedParentAuthorRelay = this.normalizedThreadParticipantRelay; - } - - if (!this.normalizedRootAuthorRelay && !this.normalizedParentCommentId) { - this.normalizedRootAuthorRelay = this.normalizedParentAuthorRelay; - } - - if (!this.normalizedRootAuthorPubkey && this.derivedDefinitionPubkey) { - this.normalizedRootAuthorPubkey = this.derivedDefinitionPubkey; - } - - if (!this.normalizedParentAuthorPubkey && !this.normalizedParentCommentId) { - this.normalizedParentAuthorPubkey = this.normalizedRootAuthorPubkey; - } - - if (!this.normalizedParentAuthorRelay && !this.normalizedParentCommentId) { - this.normalizedParentAuthorRelay = this.normalizedRootAuthorRelay; - } - } - - getDescriptor() { - const descriptor = { - videoEventId: normalizeDescriptorString(this.normalizedVideoEventId), - videoEventRelay: normalizeDescriptorRelay(this.normalizedVideoEventRelay), - videoDefinitionAddress: normalizeDescriptorString(this.normalizedVideoDefinitionAddress), - videoDefinitionRelay: normalizeDescriptorRelay(this.normalizedVideoDefinitionRelay), - videoKind: normalizeDescriptorString(this.normalizedVideoKind), - videoAuthorPubkey: normalizeDescriptorString(this.normalizedVideoAuthorPubkey), - parentCommentId: normalizeDescriptorString(this.normalizedParentCommentId), - parentCommentRelay: normalizeDescriptorRelay(this.normalizedParentCommentRelay), - threadParticipantPubkey: normalizeDescriptorString(this.normalizedThreadParticipantPubkey), - threadParticipantRelay: normalizeDescriptorRelay(this.normalizedThreadParticipantRelay), - rootIdentifier: normalizeDescriptorString(this.normalizedRootIdentifier), - rootIdentifierRelay: normalizeDescriptorRelay(this.normalizedRootIdentifierRelay), - parentIdentifier: normalizeDescriptorString(this.normalizedParentIdentifier), - parentIdentifierRelay: normalizeDescriptorRelay(this.normalizedParentIdentifierRelay), - rootKind: normalizeDescriptorString(this.normalizedRootKind), - rootAuthorPubkey: normalizeDescriptorString(this.normalizedRootAuthorPubkey), - rootAuthorRelay: normalizeDescriptorRelay(this.normalizedRootAuthorRelay), - parentKind: normalizeDescriptorString(this.normalizedParentKind), - parentAuthorPubkey: normalizeDescriptorString(this.normalizedParentAuthorPubkey), - parentAuthorRelay: normalizeDescriptorRelay(this.normalizedParentAuthorRelay), - }; - - if (!descriptor.videoEventId) { - return null; - } - - return descriptor; - } -} - -function normalizeCommentTarget(targetInput = {}, overrides = {}) { - return new CommentTargetNormalizer(targetInput, overrides).normalize(); -} - function applyFilterOptions(filter, options = {}) { if (!filter || typeof filter !== "object") { return filter; @@ -973,191 +170,6 @@ function flattenListResults(input) { return flat; } -function isVideoCommentEvent(event, descriptor) { - if (!event || typeof event !== "object") { - return false; - } - - if (!getAllowedCommentKinds().includes(Number(event.kind))) { - return false; - } - - const targetDescriptor = - descriptor && typeof descriptor === "object" ? descriptor : {}; - - const tags = Array.isArray(event.tags) ? event.tags : []; - const normalizedRootIdentifier = normalizeTagValue( - targetDescriptor.rootIdentifier, - ); - const normalizedVideoDefinitionAddress = normalizeTagValue( - targetDescriptor.videoDefinitionAddress, - ); - const normalizedVideoEventId = normalizeTagValue( - targetDescriptor.videoEventId, - ); - const normalizedParentCommentId = normalizeTagValue( - targetDescriptor.parentCommentId, - ); - - const requiresRootIdentifierMatch = Boolean(normalizedRootIdentifier); - const requiresAddressMatch = - !requiresRootIdentifierMatch && Boolean(normalizedVideoDefinitionAddress); - const requiresEventMatch = - !requiresRootIdentifierMatch && - !requiresAddressMatch && - Boolean(normalizedVideoEventId); - const requiresParentTag = Boolean(normalizedParentCommentId); - - let hasIdentifierTag = !requiresRootIdentifierMatch; - let hasDefinitionTag = !requiresAddressMatch; - let hasEventTag = !requiresEventMatch; - let hasParentTag = false; - - let matchedDefinitionPointer = false; - let matchedEventPointer = false; - - const expectedRootKind = normalizeDescriptorString( - targetDescriptor.rootKind || targetDescriptor.videoKind, - ); - - const expectedRootAuthor = normalizeDescriptorString( - targetDescriptor.rootAuthorPubkey || targetDescriptor.videoAuthorPubkey, - ); - - const expectedParentKind = normalizeDescriptorString(targetDescriptor.parentKind); - - const expectedParentAuthor = normalizeDescriptorString( - targetDescriptor.parentAuthorPubkey, - ); - - const kindTagValues = []; - const authorTagValues = []; - - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - const [rawName, rawValue] = tag; - const trimmedName = typeof rawName === "string" ? rawName.trim() : ""; - const upperName = trimmedName.toUpperCase(); - const normalizedValue = normalizeTagValue(rawValue); - if (!upperName || !normalizedValue) { - continue; - } - - if (upperName === "I") { - if ( - normalizedRootIdentifier && - normalizedValue === normalizedRootIdentifier - ) { - hasIdentifierTag = true; - } - } else if (upperName === "A") { - if ( - normalizedVideoDefinitionAddress && - normalizedValue === normalizedVideoDefinitionAddress - ) { - hasDefinitionTag = true; - matchedDefinitionPointer = true; - } - } else if (upperName === "E") { - if ( - normalizedVideoEventId && - normalizedValue === normalizedVideoEventId - ) { - hasEventTag = true; - matchedEventPointer = true; - } - if ( - normalizedParentCommentId && - normalizedValue === normalizedParentCommentId - ) { - hasParentTag = true; - } - } else if (upperName === "K") { - kindTagValues.push(normalizedValue); - } else if (upperName === "P") { - authorTagValues.push(normalizedValue); - } - } - - const sawRootKindTag = kindTagValues.length > 0 && Boolean(expectedRootKind); - const sawRootAuthorTag = authorTagValues.length > 0 && Boolean(expectedRootAuthor); - const sawParentKindTag = kindTagValues.length > 0 && Boolean(expectedParentKind); - const sawParentAuthorTag = - authorTagValues.length > 0 && Boolean(expectedParentAuthor); - - const rootKindMatches = - !expectedRootKind || kindTagValues.includes(expectedRootKind); - const rootAuthorMatches = - !expectedRootAuthor || authorTagValues.includes(expectedRootAuthor); - const parentKindMatches = - !expectedParentKind || kindTagValues.includes(expectedParentKind); - const parentAuthorMatches = - !expectedParentAuthor || authorTagValues.includes(expectedParentAuthor); - - if (!hasDefinitionTag && requiresAddressMatch && matchedEventPointer) { - hasDefinitionTag = true; - } - - if (!hasIdentifierTag && requiresRootIdentifierMatch) { - if (matchedDefinitionPointer || matchedEventPointer) { - devLogger.debug( - "[nostr] Comment accepted without explicit identifier tag due to pointer match", - { - eventId: event?.id, - matchedDefinitionPointer, - matchedEventPointer, - }, - ); - hasIdentifierTag = true; - } - } - - if (!hasIdentifierTag || !hasDefinitionTag || !hasEventTag) { - if (matchedEventPointer && (!requiresParentTag || hasParentTag)) { - devLogger.debug( - "[nostr] Comment accepted via legacy pointer fallback", - { - eventId: event?.id, - hasIdentifierTag, - hasDefinitionTag, - hasEventTag, - requiresParentTag, - hasParentTag, - }, - ); - hasIdentifierTag = true; - hasDefinitionTag = true; - hasEventTag = true; - } else { - return false; - } - } - - if (requiresParentTag && !hasParentTag) { - return false; - } - - if (sawRootKindTag && expectedRootKind && !rootKindMatches) { - return false; - } - - if (sawRootAuthorTag && expectedRootAuthor && !rootAuthorMatches) { - return false; - } - - if (sawParentKindTag && expectedParentKind && !parentKindMatches) { - return false; - } - - if (sawParentAuthorTag && expectedParentAuthor && !parentAuthorMatches) { - return false; - } - - return true; -} - export async function publishComment( client, targetInput, diff --git a/js/nostr/commentTargetNormalizer.js b/js/nostr/commentTargetNormalizer.js new file mode 100644 index 00000000..7d8f1f6c --- /dev/null +++ b/js/nostr/commentTargetNormalizer.js @@ -0,0 +1,982 @@ +import { + getNostrEventSchema, + NOTE_TYPES, +} from "../nostrEventSchemas.js"; +import { normalizePointerInput } from "./watchHistory.js"; +import { devLogger } from "../utils/logger.js"; + +const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); +export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind) + ? COMMENT_EVENT_SCHEMA.kind + : 1111; +export const LEGACY_COMMENT_KIND = 1; +const ALLOWED_COMMENT_KINDS = Object.freeze( + COMMENT_EVENT_KIND === LEGACY_COMMENT_KIND + ? [COMMENT_EVENT_KIND] + : [COMMENT_EVENT_KIND, LEGACY_COMMENT_KIND] +); + +export function getAllowedCommentKinds() { + return ALLOWED_COMMENT_KINDS.slice(); +} + +export function normalizeRelay(candidate) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + if (Array.isArray(candidate)) { + const relayCandidate = candidate[2]; + if (typeof relayCandidate === "string" && relayCandidate.trim()) { + return relayCandidate.trim(); + } + } + if (candidate && typeof candidate === "object") { + if (typeof candidate.relay === "string" && candidate.relay.trim()) { + return candidate.relay.trim(); + } + if (typeof candidate.url === "string" && candidate.url.trim()) { + return candidate.url.trim(); + } + if (Array.isArray(candidate.relays)) { + for (const relayCandidate of candidate.relays) { + if (typeof relayCandidate === "string" && relayCandidate.trim()) { + return relayCandidate.trim(); + } + } + } + if (candidate.tag) { + return normalizeRelay(candidate.tag); + } + if (candidate.pointer) { + return normalizeRelay(candidate.pointer); + } + } + return ""; +} + +export function normalizePointerCandidate(candidate, expectedType) { + if (!candidate) { + return null; + } + + if (Array.isArray(candidate)) { + const tagName = + typeof candidate[0] === "string" ? candidate[0].trim().toLowerCase() : ""; + if ( + tagName === expectedType && + typeof candidate[1] === "string" && + candidate[1].trim() + ) { + return { + value: candidate[1].trim(), + relay: normalizeRelay(candidate), + }; + } + return null; + } + + if (typeof candidate === "string") { + const pointer = normalizePointerInput(candidate); + if (pointer?.type === expectedType && pointer.value) { + return { + value: pointer.value.trim(), + relay: normalizeRelay(pointer), + }; + } + if (expectedType === "e" && candidate.trim() && !candidate.includes(":")) { + return { value: candidate.trim(), relay: "" }; + } + if (expectedType === "a" && candidate.trim() && candidate.includes(":")) { + return { value: candidate.trim(), relay: "" }; + } + return null; + } + + if (candidate && typeof candidate === "object") { + if ( + typeof candidate.type === "string" && + candidate.type.toLowerCase() === expectedType && + typeof candidate.value === "string" && + candidate.value.trim() + ) { + return { + value: candidate.value.trim(), + relay: normalizeRelay(candidate), + }; + } + if ( + expectedType === "e" && + typeof candidate.id === "string" && + candidate.id.trim() + ) { + return { + value: candidate.id.trim(), + relay: normalizeRelay(candidate), + }; + } + if ( + expectedType === "a" && + typeof candidate.address === "string" && + candidate.address.trim() + ) { + return { + value: candidate.address.trim(), + relay: normalizeRelay(candidate), + }; + } + if (candidate.tag) { + return normalizePointerCandidate(candidate.tag, expectedType); + } + if (candidate.pointer) { + return normalizePointerCandidate(candidate.pointer, expectedType); + } + } + + return null; +} + +export function normalizeTagName(name) { + return typeof name === "string" ? name.trim().toLowerCase() : ""; +} + +export function normalizeTagValue(value) { + if (value == null) return ""; + if (Number.isFinite(value)) return String(Math.floor(value)); + return typeof value === "string" ? (value.trim().toLowerCase() || "") : ""; +} + +export function normalizeDescriptorString(value) { + if (value == null) return ""; + if (Number.isFinite(value)) return String(Math.floor(value)); + if (typeof value === "string") return value.trim().toLowerCase() || ""; + if (typeof value === "object" && value.value !== undefined) { + return normalizeDescriptorString(value.value); + } + return ""; +} + +export function normalizeDescriptorRelay(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function pickString(...candidates) { + for (const c of candidates) { + if (typeof c === "string" && c.trim()) return c.trim(); + } + return ""; +} + +export function pickKind(...candidates) { + for (const c of candidates) { + if (Number.isFinite(c)) return String(Math.floor(c)); + if (typeof c === "string" && c.trim()) return c.trim(); + } + return ""; +} + +export 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; +} + +export function resolveEventCandidate(...candidates) { + for (const candidate of candidates) { + if (isEventCandidate(candidate)) { + return candidate; + } + } + return null; +} + +export 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); +} + +export function findTagByName(tags, ...names) { + const normalizedNames = names + .flat() + .map((name) => normalizeTagName(name)) + .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(normalizeTagName(name))) { + return tag; + } + } + return null; +} + +export class CommentTargetNormalizer { + constructor(targetInput, overrides) { + this.target = targetInput && typeof targetInput === "object" ? targetInput : {}; + this.options = overrides && typeof overrides === "object" ? overrides : {}; + + // Pointers + this.videoEventPointer = null; + this.videoDefinitionPointer = null; + this.rootDefinitionPointer = null; + this.rootEventPointer = null; + this.parentCommentPointer = null; + + // Events + this.videoEventObject = null; + this.parentCommentEvent = null; + + // Tags + this.rootEventTags = []; + this.parentEventTags = []; + this.combinedTags = []; + + // Normalized Values + this.normalizedVideoEventId = ""; + this.normalizedVideoEventRelay = ""; + this.normalizedVideoDefinitionAddress = ""; + this.normalizedVideoDefinitionRelay = ""; + this.normalizedParentCommentId = ""; + this.normalizedParentCommentRelay = ""; + this.normalizedThreadParticipantPubkey = ""; + this.normalizedThreadParticipantRelay = ""; + this.normalizedRootIdentifier = ""; + this.normalizedRootIdentifierRelay = ""; + this.normalizedParentIdentifier = ""; + this.normalizedParentIdentifierRelay = ""; + this.normalizedVideoKind = ""; + this.normalizedVideoAuthorPubkey = ""; + this.derivedDefinitionKind = ""; + this.derivedDefinitionPubkey = ""; + this.normalizedRootKind = ""; + this.normalizedParentKind = ""; + this.normalizedRootAuthorPubkey = ""; + this.normalizedParentAuthorPubkey = ""; + this.normalizedRootAuthorRelay = ""; + this.normalizedParentAuthorRelay = ""; + } + + normalize() { + this.extractPointers(); + this.resolveEventsAndTags(); + this.extractInitialValues(); + this.enrichFromTags(); + this.applyDefaults(); + return this.getDescriptor(); + } + + extractPointers() { + const { target, options } = this; + this.videoEventPointer = + normalizePointerCandidate(options.videoEventPointer, "e") || + normalizePointerCandidate(options.videoEvent, "e") || + normalizePointerCandidate(target.videoEventPointer, "e") || + normalizePointerCandidate(target.videoEvent, "e") || + normalizePointerCandidate(target.eventPointer, "e") || + normalizePointerCandidate(target.event, "e"); + + this.videoDefinitionPointer = + normalizePointerCandidate(options.videoDefinitionPointer, "a") || + normalizePointerCandidate(options.videoDefinition, "a") || + normalizePointerCandidate(target.videoDefinitionPointer, "a") || + normalizePointerCandidate(target.videoDefinition, "a") || + normalizePointerCandidate(target.definitionPointer, "a") || + normalizePointerCandidate(target.definition, "a"); + + this.rootDefinitionPointer = + normalizePointerCandidate(options.rootDefinitionPointer, "a") || + normalizePointerCandidate(options.rootPointer, "a") || + normalizePointerCandidate(target.rootDefinitionPointer, "a") || + normalizePointerCandidate(target.rootPointer, "a"); + + this.rootEventPointer = + normalizePointerCandidate(options.rootEventPointer, "e") || + normalizePointerCandidate(options.rootPointer, "e") || + normalizePointerCandidate(target.rootEventPointer, "e") || + normalizePointerCandidate(target.rootPointer, "e"); + + this.parentCommentPointer = + normalizePointerCandidate(options.parentCommentPointer, "e") || + normalizePointerCandidate(options.parentComment, "e") || + normalizePointerCandidate(target.parentCommentPointer, "e") || + normalizePointerCandidate(target.parentComment, "e") || + normalizePointerCandidate(target.parentPointer, "e"); + } + + resolveEventsAndTags() { + const { target, options } = this; + this.videoEventObject = resolveEventCandidate( + options.videoEvent, + options.rootEvent, + target.videoEvent, + target.rootEvent, + ); + + this.parentCommentEvent = resolveEventCandidate( + options.parentCommentEvent, + options.parentComment, + target.parentCommentEvent, + target.parentComment, + ); + + this.rootEventTags = collectTagsFromEvent(this.videoEventObject); + this.parentEventTags = collectTagsFromEvent(this.parentCommentEvent); + this.combinedTags = [...this.parentEventTags, ...this.rootEventTags]; + } + + extractInitialValues() { + const { target, options } = this; + + const normalize = (val) => (typeof val === "string" ? val.trim() : ""); + + this.normalizedVideoEventId = normalize(pickString( + options.videoEventId, + target.videoEventId, + target.eventId, + this.videoEventPointer?.value, + this.rootEventPointer?.value, + )); + + this.normalizedVideoEventRelay = normalize(pickString( + options.videoEventRelay, + target.videoEventRelay, + target.eventRelay, + this.videoEventPointer?.relay, + this.rootEventPointer?.relay, + )); + + this.normalizedVideoDefinitionAddress = normalize(pickString( + options.videoDefinitionAddress, + target.videoDefinitionAddress, + target.definitionAddress, + this.videoDefinitionPointer?.value, + this.rootDefinitionPointer?.value, + )); + + this.normalizedVideoDefinitionRelay = normalize(pickString( + options.videoDefinitionRelay, + target.videoDefinitionRelay, + target.definitionRelay, + this.videoDefinitionPointer?.relay, + this.rootDefinitionPointer?.relay, + )); + + this.normalizedParentCommentId = normalize(pickString( + options.parentCommentId, + target.parentCommentId, + target.parentId, + this.parentCommentPointer?.value, + )); + + this.normalizedParentCommentRelay = normalize(pickString( + options.parentCommentRelay, + target.parentCommentRelay, + target.parentRelay, + this.parentCommentPointer?.relay, + )); + + this.normalizedThreadParticipantPubkey = normalize(pickString( + options.threadParticipantPubkey, + target.threadParticipantPubkey, + target.participantPubkey, + target.authorPubkey, + )); + + this.normalizedThreadParticipantRelay = normalize(pickString( + options.threadParticipantRelay, + target.threadParticipantRelay, + target.participantRelay, + )); + + this.normalizedRootIdentifier = normalize(pickString( + options.rootIdentifier, + target.rootIdentifier, + options.rootPointer, + target.rootPointer, + )); + + this.normalizedRootIdentifierRelay = normalize(pickString( + options.rootIdentifierRelay, + target.rootIdentifierRelay, + )); + + this.normalizedParentIdentifier = normalize(pickString( + options.parentIdentifier, + target.parentIdentifier, + )); + + this.normalizedParentIdentifierRelay = normalize(pickString( + options.parentIdentifierRelay, + target.parentIdentifierRelay, + )); + + this.normalizedVideoKind = pickKind( + options.videoKind, + target.videoKind, + this.videoEventObject?.kind, + ); + + this.normalizedVideoAuthorPubkey = pickString( + options.videoAuthorPubkey, + target.videoAuthorPubkey, + this.videoEventObject?.pubkey, + ); + + const definitionSegments = this.normalizedVideoDefinitionAddress + ? this.normalizedVideoDefinitionAddress.split(":") + : []; + this.derivedDefinitionKind = definitionSegments[0] || ""; + this.derivedDefinitionPubkey = definitionSegments[1] || ""; + + const rootKindCandidate = pickString( + options.rootKind, + target.rootKind, + options.videoKind, + target.videoKind, + this.normalizedVideoKind, + this.derivedDefinitionKind, + ); + this.normalizedRootKind = + typeof rootKindCandidate === "string" ? rootKindCandidate.trim() : ""; + + const parentKindCandidate = pickString( + options.parentKind, + target.parentKind, + options.parentCommentKind, + target.parentCommentKind, + pickKind(this.parentCommentEvent?.kind), + ); + this.normalizedParentKind = + typeof parentKindCandidate === "string" ? parentKindCandidate.trim() : ""; + + let normalizedRootAuthorPubkey = pickString( + options.rootAuthorPubkey, + target.rootAuthorPubkey, + options.videoAuthorPubkey, + target.videoAuthorPubkey, + this.normalizedVideoAuthorPubkey, + this.derivedDefinitionPubkey, + ); + this.normalizedRootAuthorPubkey = + typeof normalizedRootAuthorPubkey === "string" + ? normalizedRootAuthorPubkey.trim() + : ""; + + let normalizedParentAuthorPubkey = pickString( + options.parentAuthorPubkey, + target.parentAuthorPubkey, + options.parentCommentPubkey, + target.parentCommentPubkey, + this.parentCommentEvent?.pubkey, + ); + this.normalizedParentAuthorPubkey = + typeof normalizedParentAuthorPubkey === "string" + ? normalizedParentAuthorPubkey.trim() + : ""; + + let normalizedRootAuthorRelay = pickString( + options.rootAuthorRelay, + target.rootAuthorRelay, + ); + this.normalizedRootAuthorRelay = + typeof normalizedRootAuthorRelay === "string" + ? normalizedRootAuthorRelay.trim() + : ""; + + let normalizedParentAuthorRelay = pickString( + options.parentAuthorRelay, + target.parentAuthorRelay, + ); + this.normalizedParentAuthorRelay = + typeof normalizedParentAuthorRelay === "string" + ? normalizedParentAuthorRelay.trim() + : ""; + } + + enrichFromTags() { + const getTagField = (tag, index) => + Array.isArray(tag) && typeof tag[index] === "string" + ? tag[index].trim() + : ""; + + if (!this.normalizedRootIdentifier) { + const tag = findTagByName(this.combinedTags, "I"); + const value = getTagField(tag, 1); + if (value) { + this.normalizedRootIdentifier = value; + const relay = getTagField(tag, 2); + if (relay) { + this.normalizedRootIdentifierRelay = relay; + } + } + } else if (!this.normalizedRootIdentifierRelay) { + const tag = findTagByName(this.combinedTags, "I"); + const relay = getTagField(tag, 2); + if (relay) { + this.normalizedRootIdentifierRelay = relay; + } + } + + if (!this.normalizedVideoDefinitionAddress) { + const tag = findTagByName(this.combinedTags, "A", "a"); + const value = getTagField(tag, 1); + if (value) { + this.normalizedVideoDefinitionAddress = value; + const relay = getTagField(tag, 2); + if (relay) { + this.normalizedVideoDefinitionRelay = relay; + } + } + } else if (!this.normalizedVideoDefinitionRelay) { + const tag = findTagByName(this.combinedTags, "A", "a"); + const relay = getTagField(tag, 2); + if (relay) { + this.normalizedVideoDefinitionRelay = relay; + } + } + + const rootEventTag = findTagByName(this.combinedTags, "E"); + if (rootEventTag) { + const value = getTagField(rootEventTag, 1); + if (value && !this.normalizedVideoEventId) { + this.normalizedVideoEventId = value; + } + const relay = getTagField(rootEventTag, 2); + if (relay && !this.normalizedVideoEventRelay) { + this.normalizedVideoEventRelay = relay; + } + const authorHint = getTagField(rootEventTag, 3); + if (authorHint && !this.normalizedRootAuthorPubkey) { + this.normalizedRootAuthorPubkey = authorHint; + } + } + + if (!this.normalizedVideoEventId || !this.normalizedParentCommentId) { + const eventValues = []; + for (const tag of this.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 (!this.normalizedVideoEventId && eventValues.length) { + this.normalizedVideoEventId = eventValues[0].value; + const relay = getTagField(eventValues[0].tag, 2); + if (relay && !this.normalizedVideoEventRelay) { + this.normalizedVideoEventRelay = relay; + } + } + if (!this.normalizedParentCommentId && eventValues.length) { + for (let index = eventValues.length - 1; index >= 0; index -= 1) { + const candidate = eventValues[index]; + if ( + candidate.value && + candidate.value !== this.normalizedVideoEventId + ) { + this.normalizedParentCommentId = candidate.value; + const relay = getTagField(candidate.tag, 2); + if (relay) { + this.normalizedParentCommentRelay = relay; + } + const authorHint = getTagField(candidate.tag, 3); + if (authorHint && !this.normalizedParentAuthorPubkey) { + this.normalizedParentAuthorPubkey = authorHint; + } + break; + } + } + } + } + + if (this.normalizedParentCommentId && !this.normalizedParentCommentRelay) { + const parentTag = this.parentEventTags.find( + (tag) => + Array.isArray(tag) && + tag.length >= 2 && + typeof tag[0] === "string" && + tag[0].trim().toLowerCase() === "e" && + getTagField(tag, 1) === this.normalizedParentCommentId, + ); + if (parentTag) { + const relay = getTagField(parentTag, 2); + if (relay) { + this.normalizedParentCommentRelay = relay; + } + const authorHint = getTagField(parentTag, 3); + if (authorHint && !this.normalizedParentAuthorPubkey) { + this.normalizedParentAuthorPubkey = authorHint; + } + } + } + + if (!this.normalizedParentIdentifierRelay) { + const tag = findTagByName(this.parentEventTags, "i"); + const relay = getTagField(tag, 2); + if (relay) { + this.normalizedParentIdentifierRelay = relay; + } + } + + if (!this.normalizedRootKind) { + const tag = findTagByName(this.combinedTags, "K"); + const value = getTagField(tag, 1); + if (value) { + this.normalizedRootKind = value; + } + } + if (!this.normalizedVideoKind && this.normalizedRootKind) { + this.normalizedVideoKind = this.normalizedRootKind; + } + + if (!this.normalizedRootAuthorPubkey || !this.normalizedRootAuthorRelay) { + const tag = findTagByName(this.combinedTags, "P"); + if (tag) { + const value = getTagField(tag, 1); + if (value && !this.normalizedRootAuthorPubkey) { + this.normalizedRootAuthorPubkey = value; + } + const relay = getTagField(tag, 2); + if (relay && !this.normalizedRootAuthorRelay) { + this.normalizedRootAuthorRelay = relay; + } + } + } + if (!this.normalizedVideoAuthorPubkey && this.normalizedRootAuthorPubkey) { + this.normalizedVideoAuthorPubkey = this.normalizedRootAuthorPubkey; + } + + if (!this.normalizedParentKind) { + const tag = findTagByName(this.parentEventTags, "k"); + const value = getTagField(tag, 1); + if (value) { + this.normalizedParentKind = value; + } + } + + if (!this.normalizedParentAuthorRelay && this.normalizedParentAuthorPubkey) { + const parentTag = this.parentEventTags.find( + (tag) => + Array.isArray(tag) && + tag.length >= 2 && + typeof tag[0] === "string" && + tag[0].trim().toLowerCase() === "p" && + normalizeTagValue(tag[1]) === normalizeTagValue(this.normalizedParentAuthorPubkey), + ); + if (parentTag) { + const relay = getTagField(parentTag, 2); + if (relay) { + this.normalizedParentAuthorRelay = relay; + } + } + } + + if (!this.normalizedParentAuthorPubkey || !this.normalizedParentAuthorRelay) { + const tag = findTagByName(this.parentEventTags, "p"); + if (tag) { + const value = getTagField(tag, 1); + if (value && !this.normalizedParentAuthorPubkey) { + this.normalizedParentAuthorPubkey = value; + } + const relay = getTagField(tag, 2); + if (relay && !this.normalizedParentAuthorRelay) { + this.normalizedParentAuthorRelay = relay; + } + } + } + } + + applyDefaults() { + if (!this.normalizedParentKind) { + if (this.normalizedParentCommentId) { + this.normalizedParentKind = String(COMMENT_EVENT_KIND); + } else if (this.normalizedRootKind) { + this.normalizedParentKind = this.normalizedRootKind; + } else if (this.derivedDefinitionKind) { + this.normalizedParentKind = this.derivedDefinitionKind; + } + } + + if (!this.normalizedRootKind) { + this.normalizedRootKind = this.normalizedParentKind; + } + + if (!this.normalizedParentAuthorPubkey) { + this.normalizedParentAuthorPubkey = this.normalizedThreadParticipantPubkey; + } + + if (!this.normalizedRootAuthorPubkey) { + if (!this.normalizedParentCommentId && this.normalizedParentAuthorPubkey) { + this.normalizedRootAuthorPubkey = this.normalizedParentAuthorPubkey; + } + } + + if (!this.normalizedRootAuthorRelay && this.normalizedVideoDefinitionRelay) { + this.normalizedRootAuthorRelay = this.normalizedVideoDefinitionRelay; + } + + if (!this.normalizedParentAuthorRelay) { + this.normalizedParentAuthorRelay = this.normalizedThreadParticipantRelay; + } + + if (!this.normalizedRootAuthorRelay && !this.normalizedParentCommentId) { + this.normalizedRootAuthorRelay = this.normalizedParentAuthorRelay; + } + + if (!this.normalizedRootAuthorPubkey && this.derivedDefinitionPubkey) { + this.normalizedRootAuthorPubkey = this.derivedDefinitionPubkey; + } + + if (!this.normalizedParentAuthorPubkey && !this.normalizedParentCommentId) { + this.normalizedParentAuthorPubkey = this.normalizedRootAuthorPubkey; + } + + if (!this.normalizedParentAuthorRelay && !this.normalizedParentCommentId) { + this.normalizedParentAuthorRelay = this.normalizedRootAuthorRelay; + } + } + + getDescriptor() { + const descriptor = { + videoEventId: normalizeDescriptorString(this.normalizedVideoEventId), + videoEventRelay: normalizeDescriptorRelay(this.normalizedVideoEventRelay), + videoDefinitionAddress: normalizeDescriptorString(this.normalizedVideoDefinitionAddress), + videoDefinitionRelay: normalizeDescriptorRelay(this.normalizedVideoDefinitionRelay), + videoKind: normalizeDescriptorString(this.normalizedVideoKind), + videoAuthorPubkey: normalizeDescriptorString(this.normalizedVideoAuthorPubkey), + parentCommentId: normalizeDescriptorString(this.normalizedParentCommentId), + parentCommentRelay: normalizeDescriptorRelay(this.normalizedParentCommentRelay), + threadParticipantPubkey: normalizeDescriptorString(this.normalizedThreadParticipantPubkey), + threadParticipantRelay: normalizeDescriptorRelay(this.normalizedThreadParticipantRelay), + rootIdentifier: normalizeDescriptorString(this.normalizedRootIdentifier), + rootIdentifierRelay: normalizeDescriptorRelay(this.normalizedRootIdentifierRelay), + parentIdentifier: normalizeDescriptorString(this.normalizedParentIdentifier), + parentIdentifierRelay: normalizeDescriptorRelay(this.normalizedParentIdentifierRelay), + rootKind: normalizeDescriptorString(this.normalizedRootKind), + rootAuthorPubkey: normalizeDescriptorString(this.normalizedRootAuthorPubkey), + rootAuthorRelay: normalizeDescriptorRelay(this.normalizedRootAuthorRelay), + parentKind: normalizeDescriptorString(this.normalizedParentKind), + parentAuthorPubkey: normalizeDescriptorString(this.normalizedParentAuthorPubkey), + parentAuthorRelay: normalizeDescriptorRelay(this.normalizedParentAuthorRelay), + }; + + if (!descriptor.videoEventId) { + return null; + } + + return descriptor; + } +} + +export function normalizeCommentTarget(targetInput = {}, overrides = {}) { + return new CommentTargetNormalizer(targetInput, overrides).normalize(); +} + +export function isVideoCommentEvent(event, descriptor) { + if (!event || typeof event !== "object") { + return false; + } + + if (!getAllowedCommentKinds().includes(Number(event.kind))) { + return false; + } + + const targetDescriptor = + descriptor && typeof descriptor === "object" ? descriptor : {}; + + const tags = Array.isArray(event.tags) ? event.tags : []; + const normalizedRootIdentifier = normalizeTagValue( + targetDescriptor.rootIdentifier, + ); + const normalizedVideoDefinitionAddress = normalizeTagValue( + targetDescriptor.videoDefinitionAddress, + ); + const normalizedVideoEventId = normalizeTagValue( + targetDescriptor.videoEventId, + ); + const normalizedParentCommentId = normalizeTagValue( + targetDescriptor.parentCommentId, + ); + + const requiresRootIdentifierMatch = Boolean(normalizedRootIdentifier); + const requiresAddressMatch = + !requiresRootIdentifierMatch && Boolean(normalizedVideoDefinitionAddress); + const requiresEventMatch = + !requiresRootIdentifierMatch && + !requiresAddressMatch && + Boolean(normalizedVideoEventId); + const requiresParentTag = Boolean(normalizedParentCommentId); + + let hasIdentifierTag = !requiresRootIdentifierMatch; + let hasDefinitionTag = !requiresAddressMatch; + let hasEventTag = !requiresEventMatch; + let hasParentTag = false; + + let matchedDefinitionPointer = false; + let matchedEventPointer = false; + + const expectedRootKind = normalizeDescriptorString( + targetDescriptor.rootKind || targetDescriptor.videoKind, + ); + + const expectedRootAuthor = normalizeDescriptorString( + targetDescriptor.rootAuthorPubkey || targetDescriptor.videoAuthorPubkey, + ); + + const expectedParentKind = normalizeDescriptorString(targetDescriptor.parentKind); + + const expectedParentAuthor = normalizeDescriptorString( + targetDescriptor.parentAuthorPubkey, + ); + + const kindTagValues = []; + const authorTagValues = []; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const [rawName, rawValue] = tag; + const trimmedName = typeof rawName === "string" ? rawName.trim() : ""; + const upperName = trimmedName.toUpperCase(); + const normalizedValue = normalizeTagValue(rawValue); + if (!upperName || !normalizedValue) { + continue; + } + + if (upperName === "I") { + if ( + normalizedRootIdentifier && + normalizedValue === normalizedRootIdentifier + ) { + hasIdentifierTag = true; + } + } else if (upperName === "A") { + if ( + normalizedVideoDefinitionAddress && + normalizedValue === normalizedVideoDefinitionAddress + ) { + hasDefinitionTag = true; + matchedDefinitionPointer = true; + } + } else if (upperName === "E") { + if ( + normalizedVideoEventId && + normalizedValue === normalizedVideoEventId + ) { + hasEventTag = true; + matchedEventPointer = true; + } + if ( + normalizedParentCommentId && + normalizedValue === normalizedParentCommentId + ) { + hasParentTag = true; + } + } else if (upperName === "K") { + kindTagValues.push(normalizedValue); + } else if (upperName === "P") { + authorTagValues.push(normalizedValue); + } + } + + const sawRootKindTag = kindTagValues.length > 0 && Boolean(expectedRootKind); + const sawRootAuthorTag = authorTagValues.length > 0 && Boolean(expectedRootAuthor); + const sawParentKindTag = kindTagValues.length > 0 && Boolean(expectedParentKind); + const sawParentAuthorTag = + authorTagValues.length > 0 && Boolean(expectedParentAuthor); + + const rootKindMatches = + !expectedRootKind || kindTagValues.includes(expectedRootKind); + const rootAuthorMatches = + !expectedRootAuthor || authorTagValues.includes(expectedRootAuthor); + const parentKindMatches = + !expectedParentKind || kindTagValues.includes(expectedParentKind); + const parentAuthorMatches = + !expectedParentAuthor || authorTagValues.includes(expectedParentAuthor); + + if (!hasDefinitionTag && requiresAddressMatch && matchedEventPointer) { + hasDefinitionTag = true; + } + + if (!hasIdentifierTag && requiresRootIdentifierMatch) { + if (matchedDefinitionPointer || matchedEventPointer) { + devLogger.debug( + "[nostr] Comment accepted without explicit identifier tag due to pointer match", + { + eventId: event?.id, + matchedDefinitionPointer, + matchedEventPointer, + }, + ); + hasIdentifierTag = true; + } + } + + if (!hasIdentifierTag || !hasDefinitionTag || !hasEventTag) { + if (matchedEventPointer && (!requiresParentTag || hasParentTag)) { + devLogger.debug( + "[nostr] Comment accepted via legacy pointer fallback", + { + eventId: event?.id, + hasIdentifierTag, + hasDefinitionTag, + hasEventTag, + requiresParentTag, + hasParentTag, + }, + ); + hasIdentifierTag = true; + hasDefinitionTag = true; + hasEventTag = true; + } else { + return false; + } + } + + if (requiresParentTag && !hasParentTag) { + return false; + } + + if (sawRootKindTag && expectedRootKind && !rootKindMatches) { + return false; + } + + if (sawRootAuthorTag && expectedRootAuthor && !rootAuthorMatches) { + return false; + } + + if (sawParentKindTag && expectedParentKind && !parentKindMatches) { + return false; + } + + if (sawParentAuthorTag && expectedParentAuthor && !parentAuthorMatches) { + return false; + } + + return true; +}