mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- Extracted `CommentTargetNormalizer` and related helpers to `js/nostr/commentTargetNormalizer.js`. - Refactored `js/nostr/commentEvents.js` to import from the new module. - Compacted helper functions in `js/nostr/commentTargetNormalizer.js` to ensure it stays under the 1000-line limit for new files. - Verified with `npm run lint:file-size` and `tests/nostr/comment-events.test.mjs`. Co-authored-by: PR0M3TH3AN <[email protected]>
639 lines
17 KiB
JavaScript
639 lines
17 KiB
JavaScript
import {
|
|
buildCommentEvent,
|
|
getNostrEventSchema,
|
|
NOTE_TYPES,
|
|
sanitizeAdditionalTags,
|
|
} from "../nostrEventSchemas.js";
|
|
import { publishEventToRelay } from "../nostrPublish.js";
|
|
import { RELAY_URLS } from "./toolkit.js";
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { LRUCache } from "../utils/lruCache.js";
|
|
import { CACHE_POLICIES } from "./cachePolicies.js";
|
|
import { isSessionActor } from "./sessionActor.js";
|
|
import { queueSignEvent } from "./signRequestQueue.js";
|
|
import { getActiveSigner } from "../nostrClientRegistry.js";
|
|
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
|
|
import { pMap } from "../utils/asyncUtils.js";
|
|
import { RELAY_BACKGROUND_CONCURRENCY } from "./relayConstants.js";
|
|
import {
|
|
normalizeCommentTarget,
|
|
normalizeDescriptorString,
|
|
getAllowedCommentKinds,
|
|
normalizeTagValue,
|
|
isVideoCommentEvent,
|
|
} from "./commentTargetNormalizer.js";
|
|
|
|
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
|
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT];
|
|
|
|
const commentCache = new LRUCache({ maxSize: 100 });
|
|
export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind)
|
|
? COMMENT_EVENT_SCHEMA.kind
|
|
: 1111;
|
|
export const LEGACY_COMMENT_KIND = 1;
|
|
|
|
function sanitizeRelayList(primary, fallback) {
|
|
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
|
|
if (primaryList.length) {
|
|
return primaryList;
|
|
}
|
|
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
|
|
if (fallbackList.length) {
|
|
return fallbackList;
|
|
}
|
|
return sanitizeRelayUrls(RELAY_URLS);
|
|
}
|
|
|
|
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 eventFilter = {
|
|
kinds: getAllowedCommentKinds(),
|
|
"#E": [descriptor.videoEventId],
|
|
};
|
|
filters.push(applyFilterOptions(eventFilter, options));
|
|
|
|
const uppercaseFilter = { kinds: getAllowedCommentKinds() };
|
|
let hasUppercasePointer = false;
|
|
|
|
if (typeof descriptor.rootIdentifier === "string" && descriptor.rootIdentifier) {
|
|
uppercaseFilter["#I"] = [descriptor.rootIdentifier];
|
|
hasUppercasePointer = true;
|
|
|
|
} else if (
|
|
typeof descriptor.videoDefinitionAddress === "string" &&
|
|
descriptor.videoDefinitionAddress
|
|
) {
|
|
uppercaseFilter["#A"] = [descriptor.videoDefinitionAddress];
|
|
hasUppercasePointer = true;
|
|
} else if (
|
|
typeof descriptor.videoEventId === "string" &&
|
|
descriptor.videoEventId
|
|
) {
|
|
uppercaseFilter["#E"] = [descriptor.videoEventId];
|
|
hasUppercasePointer = true;
|
|
}
|
|
|
|
const normalizedRootKind = normalizeDescriptorString(
|
|
descriptor.rootKind || descriptor.videoKind,
|
|
);
|
|
if (normalizedRootKind) {
|
|
uppercaseFilter["#K"] = [normalizedRootKind];
|
|
}
|
|
|
|
const normalizedRootAuthor = normalizeDescriptorString(
|
|
descriptor.rootAuthorPubkey || descriptor.videoAuthorPubkey,
|
|
);
|
|
if (normalizedRootAuthor) {
|
|
uppercaseFilter["#P"] = [normalizedRootAuthor];
|
|
}
|
|
|
|
if (hasUppercasePointer) {
|
|
filters.push(applyFilterOptions(uppercaseFilter, options));
|
|
}
|
|
|
|
if (
|
|
descriptor.parentCommentId &&
|
|
descriptor.parentCommentId !== descriptor.videoEventId
|
|
) {
|
|
const parentUppercaseFilter = {
|
|
kinds: getAllowedCommentKinds(),
|
|
"#E": [descriptor.parentCommentId],
|
|
};
|
|
filters.push(applyFilterOptions(parentUppercaseFilter, options));
|
|
}
|
|
|
|
if (descriptor.videoDefinitionAddress) {
|
|
const definitionUppercaseFilter = {
|
|
kinds: getAllowedCommentKinds(),
|
|
"#A": [descriptor.videoDefinitionAddress],
|
|
};
|
|
|
|
if (descriptor.parentCommentId) {
|
|
definitionUppercaseFilter["#E"] = [descriptor.parentCommentId];
|
|
}
|
|
|
|
filters.push(applyFilterOptions(definitionUppercaseFilter, 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;
|
|
}
|
|
|
|
export async function publishComment(
|
|
client,
|
|
targetInput,
|
|
options = {},
|
|
{
|
|
shouldRequestExtensionPermissions,
|
|
DEFAULT_NIP07_PERMISSION_METHODS,
|
|
resolveActiveSigner,
|
|
} = {},
|
|
) {
|
|
if (!client?.pool) {
|
|
return { ok: false, error: "nostr-uninitialized" };
|
|
}
|
|
|
|
if (isSessionActor(client)) {
|
|
const error = new Error(
|
|
"Publishing comments is not allowed for session actors."
|
|
);
|
|
error.code = "session-actor-publish-blocked";
|
|
return { ok: false, error: "session-actor-publish-blocked", details: error };
|
|
}
|
|
|
|
const descriptor = normalizeCommentTarget(targetInput, options);
|
|
if (!descriptor) {
|
|
return { ok: false, error: "invalid-target" };
|
|
}
|
|
|
|
const actorPubkey =
|
|
typeof client?.pubkey === "string" && client.pubkey.trim()
|
|
? client.pubkey.trim()
|
|
: "";
|
|
if (!actorPubkey) {
|
|
return { ok: false, error: "auth-required" };
|
|
}
|
|
|
|
const createdAt =
|
|
typeof options.created_at === "number" && options.created_at > 0
|
|
? Math.floor(options.created_at)
|
|
: Math.floor(Date.now() / 1000);
|
|
|
|
const additionalTags = sanitizeAdditionalTags(options.additionalTags);
|
|
|
|
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,
|
|
rootIdentifier: descriptor.rootIdentifier,
|
|
rootIdentifierRelay: descriptor.rootIdentifierRelay,
|
|
parentCommentId: descriptor.parentCommentId,
|
|
parentCommentRelay: descriptor.parentCommentRelay,
|
|
threadParticipantPubkey: descriptor.threadParticipantPubkey,
|
|
threadParticipantRelay: descriptor.threadParticipantRelay,
|
|
rootKind: descriptor.rootKind,
|
|
rootAuthorPubkey: descriptor.rootAuthorPubkey,
|
|
rootAuthorRelay: descriptor.rootAuthorRelay,
|
|
parentKind: descriptor.parentKind,
|
|
parentAuthorPubkey: descriptor.parentAuthorPubkey,
|
|
parentAuthorRelay: descriptor.parentAuthorRelay,
|
|
parentIdentifier: descriptor.parentIdentifier,
|
|
parentIdentifierRelay: descriptor.parentIdentifierRelay,
|
|
additionalTags,
|
|
content,
|
|
});
|
|
|
|
let signedEvent = null;
|
|
|
|
const signer = resolveActiveSigner
|
|
? resolveActiveSigner()
|
|
: getActiveSigner();
|
|
|
|
if (!signer || typeof signer.signEvent !== "function") {
|
|
const error = new Error(
|
|
"Login required: an active signer is needed to publish comments."
|
|
);
|
|
error.code = "auth-required";
|
|
return {
|
|
ok: false,
|
|
error: "auth-required",
|
|
message: error.message,
|
|
details: error,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
userLogger.warn(
|
|
"[nostr] Active signer permissions missing; comment publish requires login.",
|
|
permissionResult.error,
|
|
);
|
|
return {
|
|
ok: false,
|
|
error: "auth-required",
|
|
details: permissionResult,
|
|
};
|
|
}
|
|
|
|
try {
|
|
signedEvent = await queueSignEvent(signer, event, {
|
|
timeoutMs: options?.timeoutMs,
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
"[nostr] Failed to sign comment event with active signer:",
|
|
error,
|
|
);
|
|
return { ok: false, error: "signing-failed", details: error };
|
|
}
|
|
|
|
const relayList = sanitizeRelayList(options.relays, client.relays);
|
|
|
|
const publishResults = await pMap(
|
|
relayList,
|
|
(url) => publishEventToRelay(client.pool, url, signedEvent),
|
|
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
|
|
);
|
|
|
|
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 = {}) {
|
|
let pool = client?.pool;
|
|
const ensurePool =
|
|
typeof client?.ensurePool === "function"
|
|
? client.ensurePool.bind(client)
|
|
: null;
|
|
|
|
if (!pool || typeof pool.list !== "function") {
|
|
if (ensurePool) {
|
|
try {
|
|
pool = await ensurePool();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Unable to list video comments: pool init failed.",
|
|
error,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!pool || typeof pool.list !== "function") {
|
|
devLogger.warn("[nostr] Unable to list video comments: pool missing.");
|
|
return [];
|
|
}
|
|
|
|
let descriptor;
|
|
let filterTemplate;
|
|
try {
|
|
// createVideoCommentFilters returns { descriptor, filters: [...] }
|
|
const result = createVideoCommentFilters(targetInput, options);
|
|
descriptor = result.descriptor;
|
|
// We assume the first filter is the primary one we want to augment with 'since'
|
|
// or use in per-relay logic. For now, we use the full array.
|
|
filterTemplate = result.filters;
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to build comment filters:", error);
|
|
return [];
|
|
}
|
|
|
|
const cacheKey = descriptor.videoEventId;
|
|
const cached = commentCache.get(cacheKey);
|
|
const ttl = CACHE_POLICY.ttl;
|
|
const now = Date.now();
|
|
const forceRefresh = options?.forceRefresh === true;
|
|
|
|
if (cached && !forceRefresh && (now - cached.fetchedAt < ttl)) {
|
|
devLogger.debug(`[nostr] Comments cache hit for ${cacheKey}`);
|
|
return cached.items.filter((event) =>
|
|
isVideoCommentEvent(event, descriptor),
|
|
);
|
|
}
|
|
|
|
if (cached) {
|
|
devLogger.debug(`[nostr] Comments cache stale for ${cacheKey}, refreshing...`);
|
|
} else {
|
|
devLogger.debug(`[nostr] Comments cache miss for ${cacheKey}`);
|
|
}
|
|
|
|
const relayList = sanitizeRelayList(options.relays, client.relays);
|
|
const lastSeens = cached?.lastSeenPerRelay || {};
|
|
const mergedLastSeens = { ...lastSeens };
|
|
|
|
const rawResults = [];
|
|
|
|
// Parallel fetch from relays with incremental logic
|
|
await pMap(
|
|
relayList,
|
|
async (url) => {
|
|
if (!url) return;
|
|
const lastSeen = lastSeens[url] || 0;
|
|
// Deep clone filters to apply 'since' safely per relay
|
|
const relayFilters = filterTemplate.map(f => {
|
|
const copy = { ...f };
|
|
if (lastSeen > 0) {
|
|
copy.since = lastSeen + 1;
|
|
}
|
|
return copy;
|
|
});
|
|
|
|
try {
|
|
const events = await pool.list([url], relayFilters);
|
|
let maxCreated = lastSeen;
|
|
if (Array.isArray(events)) {
|
|
for (const ev of events) {
|
|
rawResults.push(ev);
|
|
if (ev.created_at > maxCreated) {
|
|
maxCreated = ev.created_at;
|
|
}
|
|
}
|
|
}
|
|
if (maxCreated > lastSeen) {
|
|
mergedLastSeens[url] = maxCreated;
|
|
}
|
|
} catch (err) {
|
|
devLogger.warn(`[nostr] Failed to fetch comments from ${url}:`, err);
|
|
}
|
|
},
|
|
{ concurrency: RELAY_BACKGROUND_CONCURRENCY },
|
|
);
|
|
|
|
// If we have cached items, add them to the raw list for deduplication/merging
|
|
const combinedRaw = cached ? [...cached.items, ...rawResults] : rawResults;
|
|
const flattened = flattenListResults(combinedRaw);
|
|
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);
|
|
}
|
|
}
|
|
|
|
const finalItems = 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);
|
|
|
|
// Update cache
|
|
if (cacheKey) {
|
|
commentCache.set(cacheKey, {
|
|
items: finalItems,
|
|
lastSeenPerRelay: mergedLastSeens,
|
|
fetchedAt: Date.now()
|
|
});
|
|
}
|
|
|
|
return finalItems;
|
|
}
|
|
|
|
export function subscribeVideoComments(client, targetInput, options = {}) {
|
|
const ensurePool =
|
|
typeof client?.ensurePool === "function"
|
|
? client.ensurePool.bind(client)
|
|
: null;
|
|
|
|
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 activeSubscription = null;
|
|
let unsubscribed = false;
|
|
|
|
const ensureSubscription = async () => {
|
|
let pool = client?.pool;
|
|
if (!pool || typeof pool.sub !== "function") {
|
|
if (!ensurePool) {
|
|
devLogger.warn(
|
|
"[nostr] Unable to subscribe to video comments: pool missing.",
|
|
);
|
|
return null;
|
|
}
|
|
try {
|
|
pool = await ensurePool();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Unable to subscribe to video comments: pool init failed.",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!pool || typeof pool.sub !== "function") {
|
|
devLogger.warn(
|
|
"[nostr] Unable to subscribe to video comments: pool missing.",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const subscription = pool.sub(relayList, filters);
|
|
if (unsubscribed) {
|
|
try {
|
|
subscription?.unsub?.();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Failed to unsubscribe from video comments:",
|
|
error,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
return subscription;
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to open video comment subscription:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const subscriptionPromise = ensureSubscription().then((subscription) => {
|
|
activeSubscription = subscription;
|
|
return subscription;
|
|
});
|
|
|
|
return () => {
|
|
if (unsubscribed) {
|
|
return;
|
|
}
|
|
unsubscribed = true;
|
|
|
|
const teardown = (subscription) => {
|
|
if (subscription && typeof subscription.unsub === "function") {
|
|
try {
|
|
subscription.unsub();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[nostr] Failed to unsubscribe from video comments:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (activeSubscription) {
|
|
teardown(activeSubscription);
|
|
} else {
|
|
subscriptionPromise.finally(() => {
|
|
teardown(activeSubscription);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
export const __testExports = {
|
|
normalizeCommentTarget,
|
|
createVideoCommentFilters,
|
|
isVideoCommentEvent,
|
|
commentCache,
|
|
};
|