Files
bitvid/js/nostr/commentEvents.js
T

691 lines
19 KiB
JavaScript

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
: 1111;
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 applyFilterOptions(filter, options = {}) {
if (!filter || typeof filter !== "object") {
return filter;
}
const result = { ...filter };
if (
typeof options.limit === "number" &&
Number.isFinite(options.limit) &&
options.limit > 0
) {
result.limit = Math.floor(options.limit);
}
if (typeof options.since === "number" && Number.isFinite(options.since)) {
result.since = Math.floor(options.since);
}
if (typeof options.until === "number" && Number.isFinite(options.until)) {
result.until = Math.floor(options.until);
}
return result;
}
function createVideoCommentFilters(targetInput, options = {}) {
const descriptor = normalizeCommentTarget(targetInput, options);
if (!descriptor) {
throw new Error("Invalid video comment target supplied.");
}
const filters = [];
const baseFilter = { kinds: [COMMENT_EVENT_KIND] };
if (descriptor.videoDefinitionAddress) {
baseFilter["#a"] = [descriptor.videoDefinitionAddress];
}
if (descriptor.parentCommentId) {
baseFilter["#e"] = [descriptor.parentCommentId];
}
if (!descriptor.videoDefinitionAddress && descriptor.videoEventId) {
baseFilter["#e"] = baseFilter["#e"] || [];
if (!baseFilter["#e"].includes(descriptor.videoEventId)) {
baseFilter["#e"].push(descriptor.videoEventId);
}
}
filters.push(applyFilterOptions(baseFilter, options));
const shouldIncludeLegacyFilter =
Boolean(descriptor.videoDefinitionAddress) && Boolean(descriptor.videoEventId);
if (shouldIncludeLegacyFilter) {
const legacyFilter = {
kinds: [COMMENT_EVENT_KIND],
"#e": [descriptor.videoEventId],
};
if (descriptor.parentCommentId) {
if (!legacyFilter["#e"].includes(descriptor.parentCommentId)) {
legacyFilter["#e"].push(descriptor.parentCommentId);
}
}
filters.push(applyFilterOptions(legacyFilter, options));
}
return { descriptor, filters };
}
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 : [];
const requiresParentTag = Boolean(descriptor.parentCommentId);
const requiresAddressMatch = Boolean(descriptor.videoDefinitionAddress);
const requiresEventMatch = !requiresAddressMatch && Boolean(descriptor.videoEventId);
let hasEventTag = !requiresEventMatch;
let hasDefinitionTag = !requiresAddressMatch;
let hasParentTag = !requiresParentTag;
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,
};