Add Nostr video comment helpers

This commit is contained in:
thePR0M3TH3AN committed 2025-10-24 14:36:19 -04:00
1 parent c9a633035e
commit 9717342bfe
5 files changed
+1064

No files matched your search

+30
View File
@@ -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,
+22
View File
@@ -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,
+650
View File
@@ -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,
};
+6
View File
@@ -57,3 +57,9 @@ export {
} from "./viewEvents.js";
export { publishVideoReaction } from "./reactionEvents.js";
export {
COMMENT_EVENT_KIND,
publishComment,
listVideoComments,
subscribeVideoComments,
} from "./commentEvents.js";
+356
View File
@@ -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");
});