mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Add Nostr video comment helpers
This commit is contained in:
1 parent
c9a633035e
commit
9717342bfe
5 files changed
+1064
No files matched your search
+30
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -57,3 +57,9 @@ export {
|
||||
} from "./viewEvents.js";
|
||||
|
||||
export { publishVideoReaction } from "./reactionEvents.js";
|
||||
export {
|
||||
COMMENT_EVENT_KIND,
|
||||
publishComment,
|
||||
listVideoComments,
|
||||
subscribeVideoComments,
|
||||
} from "./commentEvents.js";
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user