diff --git a/js/nostr.js b/js/nostr.js index 4746aaa2..afbbd8bf 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -22,6 +22,36 @@ export const publishVideoReaction = (...args) => { return defaultNostrClient.publishVideoReaction(...args); }; +export const publishVideoComment = (...args) => { + if ( + !defaultNostrClient || + typeof defaultNostrClient.publishVideoComment !== "function" + ) { + throw new Error("Video comment publishing is unavailable in this build."); + } + return defaultNostrClient.publishVideoComment(...args); +}; + +export const fetchVideoComments = (...args) => { + if ( + !defaultNostrClient || + typeof defaultNostrClient.fetchVideoComments !== "function" + ) { + throw new Error("Video comment fetching is unavailable in this build."); + } + return defaultNostrClient.fetchVideoComments(...args); +}; + +export const subscribeVideoComments = (...args) => { + if ( + !defaultNostrClient || + typeof defaultNostrClient.subscribeVideoComments !== "function" + ) { + throw new Error("Video comment subscriptions are unavailable in this build."); + } + return defaultNostrClient.subscribeVideoComments(...args); +}; + export { __testExports, NostrClient, diff --git a/js/nostr/client.js b/js/nostr/client.js index b73bd5bc..6aa52737 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -53,6 +53,11 @@ import { recordVideoView as recordVideoViewForClient, } from "./viewEvents.js"; import { publishVideoReaction as publishVideoReactionForClient } from "./reactionEvents.js"; +import { + publishComment as publishCommentForClient, + listVideoComments as listVideoCommentsForClient, + subscribeVideoComments as subscribeVideoCommentsForClient, +} from "./commentEvents.js"; import { publishEventToRelay, publishEventToRelays, @@ -2757,6 +2762,23 @@ export class NostrClient { }); } + async publishVideoComment(target, options = {}) { + return publishCommentForClient(this, target, options, { + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey, + DEFAULT_NIP07_PERMISSION_METHODS, + }); + } + + async fetchVideoComments(target, options = {}) { + return listVideoCommentsForClient(this, target, options); + } + + subscribeVideoComments(target, options = {}) { + return subscribeVideoCommentsForClient(this, target, options); + } + async recordVideoView(videoPointer, options = {}) { return recordVideoViewForClient(this, videoPointer, options, { resolveActiveSigner, diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js new file mode 100644 index 00000000..727a46ad --- /dev/null +++ b/js/nostr/commentEvents.js @@ -0,0 +1,650 @@ +import { + buildCommentEvent, + getNostrEventSchema, + NOTE_TYPES, +} 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"; + +const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); +export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind) + ? COMMENT_EVENT_SCHEMA.kind + : 1; + +function sanitizeRelayList(primary, fallback) { + if (Array.isArray(primary) && primary.length) { + return primary; + } + if (Array.isArray(fallback) && fallback.length) { + return fallback; + } + return 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)) { + if ( + candidate[0] === 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 === 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 pickString(...candidates) { + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return ""; +} + +function normalizeCommentTarget(targetInput = {}, overrides = {}) { + const target = targetInput && typeof targetInput === "object" ? targetInput : {}; + const options = overrides && typeof overrides === "object" ? overrides : {}; + + const 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"); + + const 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"); + + const parentCommentPointer = + normalizePointerCandidate(options.parentCommentPointer, "e") || + normalizePointerCandidate(options.parentComment, "e") || + normalizePointerCandidate(target.parentCommentPointer, "e") || + normalizePointerCandidate(target.parentComment, "e") || + normalizePointerCandidate(target.parentPointer, "e"); + + const videoEventId = pickString( + options.videoEventId, + target.videoEventId, + target.eventId, + videoEventPointer?.value, + ); + const videoEventRelay = pickString( + options.videoEventRelay, + target.videoEventRelay, + target.eventRelay, + videoEventPointer?.relay, + ); + + const videoDefinitionAddress = pickString( + options.videoDefinitionAddress, + target.videoDefinitionAddress, + target.definitionAddress, + videoDefinitionPointer?.value, + ); + const videoDefinitionRelay = pickString( + options.videoDefinitionRelay, + target.videoDefinitionRelay, + target.definitionRelay, + videoDefinitionPointer?.relay, + ); + + const parentCommentId = pickString( + options.parentCommentId, + target.parentCommentId, + target.parentId, + parentCommentPointer?.value, + ); + const parentCommentRelay = pickString( + options.parentCommentRelay, + target.parentCommentRelay, + target.parentRelay, + parentCommentPointer?.relay, + ); + + const threadParticipantPubkey = pickString( + options.threadParticipantPubkey, + target.threadParticipantPubkey, + target.participantPubkey, + target.authorPubkey, + ); + const threadParticipantRelay = pickString( + options.threadParticipantRelay, + target.threadParticipantRelay, + target.participantRelay, + ); + + if (!videoEventId || !videoDefinitionAddress) { + return null; + } + + return { + videoEventId, + videoEventRelay, + videoDefinitionAddress, + videoDefinitionRelay, + parentCommentId, + parentCommentRelay, + threadParticipantPubkey, + threadParticipantRelay, + }; +} + +function createVideoCommentFilters(targetInput, options = {}) { + const descriptor = normalizeCommentTarget(targetInput, options); + if (!descriptor) { + throw new Error("Invalid video comment target supplied."); + } + + const filter = { + kinds: [COMMENT_EVENT_KIND], + "#e": [descriptor.videoEventId], + "#a": [descriptor.videoDefinitionAddress], + }; + + if (descriptor.parentCommentId) { + if (!filter["#e"].includes(descriptor.parentCommentId)) { + filter["#e"].push(descriptor.parentCommentId); + } + } + + if ( + typeof options.limit === "number" && + Number.isFinite(options.limit) && + options.limit > 0 + ) { + filter.limit = Math.floor(options.limit); + } + + if (typeof options.since === "number" && Number.isFinite(options.since)) { + filter.since = Math.floor(options.since); + } + + if (typeof options.until === "number" && Number.isFinite(options.until)) { + filter.until = Math.floor(options.until); + } + + return { descriptor, filters: [filter] }; +} + +function flattenListResults(input) { + if (!Array.isArray(input)) { + return []; + } + + const flat = []; + for (const chunk of input) { + if (Array.isArray(chunk)) { + for (const item of chunk) { + if (item && typeof item === "object") { + flat.push(item); + } + } + } else if (chunk && typeof chunk === "object") { + flat.push(chunk); + } + } + return flat; +} + +function isVideoCommentEvent(event, descriptor) { + if (!event || typeof event !== "object") { + return false; + } + + if (Number(event.kind) !== COMMENT_EVENT_KIND) { + return false; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let hasEventTag = false; + let hasDefinitionTag = false; + let hasParentTag = !descriptor.parentCommentId; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const [name, value] = tag; + if (name === "e" && typeof value === "string") { + if (value === descriptor.videoEventId) { + hasEventTag = true; + } + if (descriptor.parentCommentId && value === descriptor.parentCommentId) { + hasParentTag = true; + } + } else if (name === "a" && typeof value === "string") { + if (value === descriptor.videoDefinitionAddress) { + hasDefinitionTag = true; + } + } + } + + return hasEventTag && hasDefinitionTag && hasParentTag; +} + +export async function publishComment( + client, + targetInput, + options = {}, + { + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey, + DEFAULT_NIP07_PERMISSION_METHODS, + } = {}, +) { + if (!client?.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const descriptor = normalizeCommentTarget(targetInput, options); + if (!descriptor) { + return { ok: false, error: "invalid-target" }; + } + + const actorPubkey = await client.ensureSessionActor(); + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + const createdAt = + typeof options.created_at === "number" && options.created_at > 0 + ? Math.floor(options.created_at) + : Math.floor(Date.now() / 1000); + + const additionalTags = Array.isArray(options.additionalTags) + ? options.additionalTags.filter( + (tag) => Array.isArray(tag) && typeof tag[0] === "string", + ) + : []; + + let content = ""; + if (typeof options.content === "string") { + content = options.content; + } else if ( + options.content && + typeof options.content === "object" && + !Array.isArray(options.content) + ) { + try { + content = JSON.stringify(options.content); + } catch (error) { + devLogger.warn("[nostr] Failed to serialize comment content:", error); + content = ""; + } + } else if (options.content !== undefined && options.content !== null) { + content = String(options.content); + } + + const event = buildCommentEvent({ + pubkey: actorPubkey, + created_at: createdAt, + videoEventId: descriptor.videoEventId, + videoEventRelay: descriptor.videoEventRelay, + videoDefinitionAddress: descriptor.videoDefinitionAddress, + videoDefinitionRelay: descriptor.videoDefinitionRelay, + parentCommentId: descriptor.parentCommentId, + parentCommentRelay: descriptor.parentCommentRelay, + threadParticipantPubkey: descriptor.threadParticipantPubkey, + threadParticipantRelay: descriptor.threadParticipantRelay, + additionalTags, + content, + }); + + let signedEvent = null; + + const normalizedActor = + typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : ""; + const normalizedLogged = + typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : ""; + + const resolveSignerFn = + typeof resolveActiveSigner === "function" ? resolveActiveSigner : null; + const signer = resolveSignerFn ? resolveSignerFn(actorPubkey) : null; + + const canUseActiveSigner = + normalizedActor && + normalizedActor === normalizedLogged && + signer && + typeof signer.signEvent === "function"; + + if (canUseActiveSigner) { + let permissionResult = { ok: true }; + const shouldRequestPermissions = + typeof shouldRequestExtensionPermissions === "function" + ? shouldRequestExtensionPermissions(signer) + : false; + + if (shouldRequestPermissions) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } + + if (permissionResult.ok) { + try { + signedEvent = await signer.signEvent(event); + } catch (error) { + userLogger.warn( + "[nostr] Failed to sign comment event with active signer:", + error, + ); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + userLogger.warn( + "[nostr] Active signer permissions missing; signing comment with session key.", + permissionResult.error, + ); + } + } + + if (!signedEvent) { + if (typeof signEventWithPrivateKey !== "function") { + return { ok: false, error: "signing-unavailable" }; + } + try { + if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) { + await client.ensureSessionActor(true); + } + if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) { + throw new Error("session-actor-mismatch"); + } + signedEvent = signEventWithPrivateKey( + event, + client.sessionActor.privateKey, + ); + } catch (error) { + userLogger.warn("[nostr] Failed to sign comment event with session key:", error); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const relayList = sanitizeRelayList(options.relays, client.relays); + + const publishResults = await Promise.all( + relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent)), + ); + + const acceptedRelays = publishResults + .filter((result) => result.success) + .map((result) => result.url) + .filter((url) => typeof url === "string" && url); + + const success = acceptedRelays.length > 0; + + if (success) { + devLogger.info( + `[nostr] Comment event accepted by ${acceptedRelays.length} relay(s):`, + acceptedRelays.join(", "), + ); + } else { + userLogger.warn("[nostr] Comment event rejected by relays:", publishResults); + } + + return { + ok: success, + event: signedEvent, + results: publishResults, + acceptedRelays, + }; +} + +export async function listVideoComments(client, targetInput, options = {}) { + const pool = client?.pool; + const canQueryPool = pool && typeof pool.list === "function"; + + if (!canQueryPool) { + devLogger.warn("[nostr] Unable to list video comments: pool missing."); + return []; + } + + let descriptor; + let filters; + try { + ({ descriptor, filters } = createVideoCommentFilters(targetInput, options)); + } catch (error) { + devLogger.warn("[nostr] Failed to build comment filters:", error); + return []; + } + + const relayList = sanitizeRelayList(options.relays, client.relays); + + let rawResults; + try { + rawResults = await pool.list(relayList, filters); + } catch (error) { + devLogger.warn("[nostr] Failed to list video comments:", error); + return []; + } + + const flattened = flattenListResults(rawResults); + const dedupe = new Map(); + const order = []; + + for (const event of flattened) { + if (!isVideoCommentEvent(event, descriptor)) { + continue; + } + + const eventId = typeof event.id === "string" ? event.id : null; + if (!eventId) { + order.push({ type: "raw", event }); + continue; + } + + const existing = dedupe.get(eventId); + if (!existing) { + dedupe.set(eventId, event); + order.push({ type: "id", key: eventId }); + continue; + } + + const existingCreated = Number.isFinite(existing?.created_at) + ? existing.created_at + : 0; + const incomingCreated = Number.isFinite(event.created_at) + ? event.created_at + : 0; + if (incomingCreated > existingCreated) { + dedupe.set(eventId, event); + } + } + + return order + .map((entry) => { + if (!entry) { + return null; + } + if (entry.type === "raw") { + return entry.event || null; + } + if (entry.type === "id") { + return dedupe.get(entry.key) || null; + } + return null; + }) + .filter(Boolean); +} + +export function subscribeVideoComments(client, targetInput, options = {}) { + const pool = client?.pool; + const canSubscribe = pool && typeof pool.sub === "function"; + + if (!canSubscribe) { + devLogger.warn("[nostr] Unable to subscribe to video comments: pool missing."); + return () => {}; + } + + let descriptor; + let filters; + try { + ({ descriptor, filters } = createVideoCommentFilters(targetInput, options)); + } catch (error) { + devLogger.warn("[nostr] Failed to build comment subscription filters:", error); + return () => {}; + } + + const relayList = sanitizeRelayList(options.relays, client.relays); + const onEvent = typeof options.onEvent === "function" ? options.onEvent : null; + + let subscription; + try { + subscription = pool.sub(relayList, filters); + } catch (error) { + devLogger.warn("[nostr] Failed to open video comment subscription:", error); + return () => {}; + } + + if (onEvent && subscription && typeof subscription.on === "function") { + try { + subscription.on("event", (event) => { + if (isVideoCommentEvent(event, descriptor)) { + try { + onEvent(event); + } catch (handlerError) { + devLogger.warn("[nostr] Comment subscription handler threw:", handlerError); + } + } + }); + } catch (error) { + devLogger.warn("[nostr] Failed to attach comment subscription handler:", error); + } + } + + const originalUnsub = + subscription && typeof subscription.unsub === "function" + ? subscription.unsub.bind(subscription) + : null; + + let unsubscribed = false; + return () => { + if (unsubscribed) { + return; + } + unsubscribed = true; + if (originalUnsub) { + try { + originalUnsub(); + } catch (error) { + devLogger.warn("[nostr] Failed to unsubscribe from video comments:", error); + } + } + }; +} + +export const __testExports = { + normalizeCommentTarget, + createVideoCommentFilters, + isVideoCommentEvent, +}; diff --git a/js/nostr/index.js b/js/nostr/index.js index 0bf16db5..11fdab61 100644 --- a/js/nostr/index.js +++ b/js/nostr/index.js @@ -57,3 +57,9 @@ export { } from "./viewEvents.js"; export { publishVideoReaction } from "./reactionEvents.js"; +export { + COMMENT_EVENT_KIND, + publishComment, + listVideoComments, + subscribeVideoComments, +} from "./commentEvents.js"; diff --git a/tests/nostr/comment-events.test.mjs b/tests/nostr/comment-events.test.mjs new file mode 100644 index 00000000..c08693c2 --- /dev/null +++ b/tests/nostr/comment-events.test.mjs @@ -0,0 +1,356 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + COMMENT_EVENT_KIND, + publishComment, + listVideoComments, + subscribeVideoComments, +} from "../../js/nostr/commentEvents.js"; + +function createMockClient({ + actorPubkey = "actor-pubkey", + sessionPrivateKey = "session-private", + relays = ["wss://primary"], + publishResults = [], +} = {}) { + const publishCalls = []; + const pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + const result = publishResults.shift(); + return { + on(name, handler) { + if (name === "ok" && (!result || result.success !== false)) { + handler(); + } + if (name === "failed" && result && result.success === false) { + handler(result.error || new Error("publish failed")); + } + }, + }; + }, + list: async () => [], + sub: () => ({ on() {}, unsub() {} }), + }; + + const ensureSessionActorCalls = []; + const client = { + pool, + relays, + pubkey: actorPubkey, + sessionActor: { + pubkey: actorPubkey, + privateKey: sessionPrivateKey, + }, + ensureSessionActor: async (forceRefresh = false) => { + ensureSessionActorCalls.push(forceRefresh); + if (forceRefresh && !client.sessionActor) { + client.sessionActor = { + pubkey: actorPubkey, + privateKey: sessionPrivateKey, + }; + } + return actorPubkey; + }, + ensureExtensionPermissions: async () => ({ ok: true }), + }; + + return { client, pool, publishCalls, ensureSessionActorCalls }; +} + +function buildCommentEventTags(event) { + return Array.isArray(event?.tags) + ? event.tags.map((tag) => Array.isArray(tag) ? [...tag] : tag) + : []; +} + +test("publishComment prefers active signer when available", async () => { + const { + client, + publishCalls, + } = createMockClient({ + relays: ["wss://relay.one", "wss://relay.two"], + }); + + const signerCalls = []; + const signer = { + type: "extension", + pubkey: client.pubkey, + async signEvent(event) { + signerCalls.push(event); + return { ...event, id: "signed-by-active" }; + }, + }; + + let privateKeySignCalls = 0; + + const result = await publishComment( + client, + { + videoEventId: "video-event-id", + videoEventRelay: "wss://video-relay", + videoDefinitionAddress: "30078:deadbeef:clip", + videoDefinitionRelay: "wss://definition-relay", + parentCommentId: "parent-comment-id", + threadParticipantPubkey: "thread-participant", + }, + { + content: "Hello world", + relays: ["wss://custom"], + }, + { + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => true, + signEventWithPrivateKey: () => { + privateKeySignCalls += 1; + return null; + }, + DEFAULT_NIP07_PERMISSION_METHODS: ["signEvent"], + }, + ); + + assert.equal(result.ok, true, "publish should succeed when relay accepts the event"); + assert.deepEqual( + result.acceptedRelays, + ["wss://custom"], + "accepted relays should reflect the sanitized relay list", + ); + assert.equal( + publishCalls.length, + 1, + "exactly one publish call should be issued", + ); + assert.deepEqual( + publishCalls[0].urls, + ["wss://custom"], + "publish should target the caller supplied relays", + ); + + assert.equal(privateKeySignCalls, 0, "session key signing should be skipped when active signer succeeds"); + assert.equal(signerCalls.length, 1, "active signer should sign the event once"); + + const eventTags = buildCommentEventTags(signerCalls[0]); + const eTags = eventTags.filter((tag) => Array.isArray(tag) && tag[0] === "e"); + const aTags = eventTags.filter((tag) => Array.isArray(tag) && tag[0] === "a"); + assert.deepEqual( + eTags, + [ + ["e", "video-event-id", "wss://video-relay"], + ["e", "parent-comment-id"], + ], + "comment event should include both video and parent pointers", + ); + assert.deepEqual( + aTags, + [["a", "30078:deadbeef:clip", "wss://definition-relay"]], + "comment event should target the video definition via #a tag", + ); +}); + +test("publishComment falls back to session signer when active signer is missing", async () => { + const { + client, + publishCalls, + ensureSessionActorCalls, + } = createMockClient(); + + client.sessionActor = null; + + let privateKeySignCalls = 0; + const signEventWithPrivateKey = (event, key) => { + privateKeySignCalls += 1; + assert.equal(key, "session-private", "session key should be used when rehydrated"); + return { ...event, id: "signed-by-session" }; + }; + + const result = await publishComment( + client, + { + videoEventId: "event-1", + videoDefinitionAddress: "30078:abc:def", + }, + { content: "Fallback" }, + { + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey, + DEFAULT_NIP07_PERMISSION_METHODS: [], + }, + ); + + assert.equal(result.ok, true, "publish should succeed with session signer"); + assert.equal(privateKeySignCalls, 1, "session signing should occur exactly once"); + assert.equal( + ensureSessionActorCalls.includes(true), + true, + "ensureSessionActor should be invoked with force refresh", + ); + assert.equal(publishCalls.length, 1, "publish should still occur once"); +}); + +test("listVideoComments builds filters with both #e and #a tags", async () => { + const matchingEventLatest = { + id: "comment-2", + kind: COMMENT_EVENT_KIND, + created_at: 1700000200, + tags: [ + ["e", "video-1"], + ["a", "30078:author:clip"], + ["e", "parent-1"], + ], + }; + + const matchingEventOlder = { + id: "comment-2", + kind: COMMENT_EVENT_KIND, + created_at: 1690000000, + tags: [ + ["e", "video-1"], + ["a", "30078:author:clip"], + ["e", "parent-1"], + ], + }; + + const otherEvent = { + id: "comment-3", + kind: COMMENT_EVENT_KIND, + created_at: 1700000100, + tags: [["e", "video-1"]], + }; + + const { + client, + pool, + } = createMockClient(); + + let receivedFilters = null; + pool.list = async (relays, filters) => { + receivedFilters = { relays, filters }; + return [[matchingEventLatest, matchingEventOlder, otherEvent]]; + }; + + const events = await listVideoComments( + client, + { + videoEventId: "video-1", + videoDefinitionAddress: "30078:author:clip", + parentCommentId: "parent-1", + }, + { relays: ["wss://history"], since: 1700000000, limit: 10 }, + ); + + assert.ok(Array.isArray(events), "listing should produce an array"); + assert.equal(events.length, 1, "duplicate ids should be collapsed to the newest instance"); + assert.equal(events[0].id, "comment-2", "newest event should be retained"); + + assert.ok(receivedFilters, "pool.list should receive filters"); + assert.deepEqual( + receivedFilters.relays, + ["wss://history"], + "list should use caller provided relays", + ); + assert.equal(receivedFilters.filters.length, 1, "exactly one filter should be emitted"); + const [filter] = receivedFilters.filters; + assert.equal(filter.kinds[0], COMMENT_EVENT_KIND, "filter should target comment kind"); + assert.deepEqual( + filter["#e"], + ["video-1", "parent-1"], + "filter should bind the video and parent ids via #e", + ); + assert.deepEqual( + filter["#a"], + ["30078:author:clip"], + "filter should bind the video definition via #a", + ); + assert.equal(filter.since, 1700000000, "since option should propagate to filter"); + assert.equal(filter.limit, 10, "limit option should propagate to filter"); +}); + +test("subscribeVideoComments forwards matching events and cleans up unsubscribe", async () => { + const { + client, + pool, + } = createMockClient(); + + let handler = null; + let unsubCalls = 0; + let subscriptionArgs = null; + pool.sub = (relays, filters) => { + subscriptionArgs = { relays, filters }; + handler = null; + return { + on(name, cb) { + if (name === "event") { + handler = cb; + } + }, + unsub() { + unsubCalls += 1; + }, + relays, + filters, + }; + }; + + const receivedEvents = []; + const unsubscribe = subscribeVideoComments( + client, + { + videoEventId: "video-1", + videoDefinitionAddress: "30078:author:clip", + parentCommentId: "parent-1", + }, + { + relays: ["wss://live"], + onEvent: (event) => { + receivedEvents.push(event); + }, + }, + ); + + assert.ok(typeof unsubscribe === "function", "subscribe should return an unsubscribe function"); + assert.ok(handler, "subscription handler should be registered"); + assert.ok(subscriptionArgs, "subscription should capture args"); + assert.deepEqual( + subscriptionArgs.relays, + ["wss://live"], + "subscription should use caller provided relays", + ); + assert.equal(subscriptionArgs.filters.length, 1, "subscription should emit a single filter"); + const [subFilter] = subscriptionArgs.filters; + assert.deepEqual( + subFilter["#e"], + ["video-1", "parent-1"], + "subscription filter should bind video and parent ids", + ); + assert.deepEqual( + subFilter["#a"], + ["30078:author:clip"], + "subscription filter should bind the video definition", + ); + + handler({ + id: "comment-accepted", + kind: COMMENT_EVENT_KIND, + tags: [ + ["e", "video-1"], + ["a", "30078:author:clip"], + ["e", "parent-1"], + ], + }); + + handler({ + id: "comment-rejected", + kind: COMMENT_EVENT_KIND, + tags: [["e", "video-1"]], + }); + + assert.equal(receivedEvents.length, 1, "only matching events should reach the callback"); + assert.equal(receivedEvents[0].id, "comment-accepted", "matching event should be forwarded"); + + unsubscribe(); + unsubscribe(); + + assert.equal(unsubCalls, 1, "underlying unsubscribe should only run once"); +});