Files
bitvid/js/nostr/viewEvents.js
T

896 lines
23 KiB
JavaScript

import {
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
VIEW_COUNT_BACKFILL_MAX_DAYS,
} from "../config.js";
import {
buildViewEvent,
getNostrEventSchema,
NOTE_TYPES,
sanitizeAdditionalTags,
} 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";
import { logViewCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
const viewEventPublishMemory = new Map();
const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
export const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
? VIEW_EVENT_SCHEMA.kind
: 30079;
function resolveVideoViewPointer(pointer) {
const normalized = normalizePointerInput(pointer);
if (!normalized || typeof normalized.value !== "string") {
throw new Error("Invalid video pointer supplied for view lookup.");
}
const value = normalized.value.trim();
if (!value) {
throw new Error("Invalid video pointer supplied for view lookup.");
}
const type = normalized.type === "a" ? "a" : "e";
const descriptor = { type, value };
if (typeof normalized.relay === "string" && normalized.relay.trim()) {
descriptor.relay = normalized.relay.trim();
}
return descriptor;
}
export function createVideoViewEventFilters(pointer) {
let resolved;
if (
pointer &&
typeof pointer === "object" &&
(pointer.type === "a" || pointer.type === "e") &&
typeof pointer.value === "string"
) {
const value = pointer.value.trim();
if (!value) {
throw new Error("Invalid video pointer supplied for view lookup.");
}
resolved = { type: pointer.type === "a" ? "a" : "e", value };
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
resolved.relay = pointer.relay.trim();
}
} else {
resolved = resolveVideoViewPointer(pointer);
}
const pointerFilter = {
kinds: [VIEW_EVENT_KIND],
"#t": ["view"],
};
if (resolved.type === "a") {
pointerFilter["#a"] = [resolved.value];
} else {
pointerFilter["#e"] = [resolved.value];
}
const filters = [pointerFilter];
return { pointer: resolved, filters };
}
export function deriveViewEventBucketIndex(createdAtSeconds) {
const timestamp = Number.isFinite(createdAtSeconds)
? Math.floor(createdAtSeconds)
: Math.floor(Date.now() / 1000);
const windowSize = Math.max(
1,
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
);
return Math.floor(timestamp / windowSize);
}
export function getViewEventGuardWindowMs() {
const windowSeconds = Math.max(
1,
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
);
return windowSeconds * 1000;
}
export function deriveViewEventPointerScope(pointer) {
const pointerValue =
typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : "";
if (!pointerValue) {
return "";
}
const pointerType = pointer?.type === "a" ? "a" : "e";
return `${pointerType}:${pointerValue}`;
}
function generateViewEventEntropy() {
const cryptoRef =
(typeof globalThis !== "undefined" &&
/** @type {Crypto | undefined} */ (globalThis.crypto)) ||
null;
if (cryptoRef && typeof cryptoRef.getRandomValues === "function") {
try {
const buffer = new Uint32Array(2);
cryptoRef.getRandomValues(buffer);
return Array.from(buffer, (value) =>
value.toString(16).padStart(8, "0")
).join("");
} catch (error) {
devLogger.warn("[nostr] Failed to gather crypto entropy for view event:", error);
}
}
const fallbackA = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0");
const fallbackB = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0");
return `${fallbackA}${fallbackB}`;
}
function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) {
const scope = deriveViewEventPointerScope(pointer) || "unknown";
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "anon";
const timestamp = Number.isFinite(createdAtSeconds)
? Math.max(0, Math.floor(createdAtSeconds))
: Math.floor(Date.now() / 1000);
const entropy = generateViewEventEntropy();
return `${scope}:${normalizedActor}:${timestamp}:${entropy}`;
}
export function hasRecentViewPublish(scope, bucketIndex, actorPubkey) {
if (!scope || !Number.isFinite(bucketIndex)) {
return false;
}
const windowMs = getViewEventGuardWindowMs();
const now = Date.now();
const safeActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "anon";
let actorMemory = viewEventPublishMemory.get(safeActor);
if (!actorMemory) {
actorMemory = new Map();
viewEventPublishMemory.set(safeActor, actorMemory);
}
const entry = actorMemory.get(scope);
if (entry) {
const age = now - Number(entry.seenAt);
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
actorMemory.delete(scope);
} else if (Number(entry.bucket) === bucketIndex) {
return true;
}
}
if (typeof localStorage === "undefined") {
return false;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${safeActor}:${scope}`;
let rawValue = null;
try {
rawValue = localStorage.getItem(storageKey);
} catch (error) {
devLogger.warn("[nostr] Failed to read view guard entry:", error);
return false;
}
if (typeof rawValue !== "string" || !rawValue) {
return false;
}
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
const storedBucket = Number(storedBucketRaw);
const storedSeenAt = Number(storedSeenRaw);
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
try {
localStorage.removeItem(storageKey);
} catch (error) {
devLogger.warn("[nostr] Failed to clear corrupt view guard entry:", error);
}
return false;
}
if (now - storedSeenAt >= windowMs) {
try {
localStorage.removeItem(storageKey);
} catch (error) {
devLogger.warn("[nostr] Failed to remove expired view guard entry:", error);
}
return false;
}
actorMemory.set(scope, {
bucket: storedBucket,
seenAt: storedSeenAt,
});
return storedBucket === bucketIndex;
}
export function rememberViewPublish(scope, bucketIndex, actorPubkey) {
if (!scope || !Number.isFinite(bucketIndex)) {
return;
}
const now = Date.now();
const windowMs = getViewEventGuardWindowMs();
const safeActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "anon";
let actorMemory = viewEventPublishMemory.get(safeActor);
if (!actorMemory) {
actorMemory = new Map();
viewEventPublishMemory.set(safeActor, actorMemory);
}
const entry = actorMemory.get(scope);
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
actorMemory.delete(scope);
}
actorMemory.set(scope, {
bucket: bucketIndex,
seenAt: now,
});
if (typeof localStorage === "undefined") {
return;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${safeActor}:${scope}`;
try {
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
} catch (error) {
devLogger.warn("[nostr] Failed to persist view guard entry:", error);
}
}
function isVideoViewEvent(event, pointer) {
if (!event || typeof event !== "object") {
return false;
}
if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) {
return false;
}
const tags = Array.isArray(event.tags) ? event.tags : [];
let hasViewTag = false;
let matchesPointer = false;
const pointerValueRaw =
typeof pointer?.value === "string" ? pointer.value.trim() : "";
const pointerValueLower = pointerValueRaw.toLowerCase();
const pointerType = pointer?.type === "a" ? "a" : "e";
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const tagName = typeof tag[0] === "string" ? tag[0].toLowerCase() : "";
const tagValue = typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
if (!tagName || !tagValue) {
continue;
}
if (tagName === "t" && tagValue === "view") {
hasViewTag = true;
continue;
}
if (pointerType === "a" && tagName === "a" && tagValue === pointerValueLower) {
matchesPointer = true;
continue;
}
if (pointerType === "e" && tagName === "e" && tagValue === pointerValueLower) {
matchesPointer = true;
continue;
}
}
return hasViewTag && matchesPointer;
}
const VIEW_EVENT_COUNT_SECONDS_PER_DAY = 86_400;
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 sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return RELAY_URLS;
}
export async function listVideoViewEvents(client, pointer, options = {}) {
const pool = client?.pool;
const canQueryPool = pool && typeof pool.list === "function";
if (!canQueryPool) {
if (
client &&
Object.prototype.hasOwnProperty.call(client, "listVideoViewEvents") &&
typeof client.listVideoViewEvents === "function"
) {
return client.listVideoViewEvents(pointer, options);
}
return [];
}
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
pointer
);
const { since, until, limit, relays } = options || {};
for (const filter of filters) {
if (!filter || typeof filter !== "object") {
continue;
}
if (Number.isFinite(since)) {
filter.since = Math.floor(since);
}
if (Number.isFinite(until)) {
filter.until = Math.floor(until);
}
if (Number.isFinite(limit) && limit > 0) {
filter.limit = Math.floor(limit);
}
}
const relayList = sanitizeRelayList(relays, client.relays);
let rawResults;
try {
rawResults = await pool.list(relayList, filters);
} catch (error) {
devLogger.warn("[nostr] Failed to list video view events:", error);
return [];
}
const flattened = flattenListResults(rawResults);
const dedupe = new Map();
const order = [];
for (const event of flattened) {
if (!isVideoViewEvent(event, pointerDescriptor)) {
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 subscribeVideoViewEvents(client, pointer, options = {}) {
const pool = client?.pool;
const canSubscribe = pool && typeof pool.sub === "function";
if (!canSubscribe) {
devLogger.warn("[nostr] Unable to subscribe to view events: pool missing.");
return () => {};
}
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
pointer
);
if (Number.isFinite(options?.since)) {
for (const filter of filters) {
if (filter && typeof filter === "object") {
filter.since = Math.floor(options.since);
}
}
}
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 view subscription:", error);
return () => {};
}
if (onEvent) {
subscription.on("event", (event) => {
if (isVideoViewEvent(event, pointerDescriptor)) {
try {
onEvent(event);
} catch (error) {
devLogger.warn("[nostr] Video view event handler threw:", error);
}
}
});
}
const originalUnsub =
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 view events:",
error
);
}
}
};
}
export async function countVideoViewEvents(client, pointer, options = {}) {
const relayList =
Array.isArray(options?.relays) && options.relays.length
? options.relays
: undefined;
const fallbackListOptions = (() => {
const listOptions = {};
if (relayList) {
listOptions.relays = relayList;
}
if (Number.isFinite(options?.since)) {
listOptions.since = Math.floor(options.since);
} else {
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
const horizonDays = Number.isFinite(horizonDaysRaw)
? Math.max(0, Math.floor(horizonDaysRaw))
: 0;
if (horizonDays > 0) {
const nowSeconds = Math.floor(Date.now() / 1000);
listOptions.since = Math.max(
0,
nowSeconds - horizonDays * VIEW_EVENT_COUNT_SECONDS_PER_DAY
);
}
}
if (Number.isFinite(options?.until)) {
listOptions.until = Math.floor(options.until);
}
if (Number.isFinite(options?.limit) && options.limit > 0) {
listOptions.limit = Math.floor(options.limit);
}
return listOptions;
})();
const canAttemptCount =
typeof client?.countEventsAcrossRelays === "function";
if (!canAttemptCount) {
const events = await listVideoViewEvents(client, pointer, {
...fallbackListOptions,
});
return {
total: Array.isArray(events) ? events.length : 0,
perRelay: [],
best: null,
fallback: true,
};
}
const { filters } = createVideoViewEventFilters(pointer);
const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
if (!pointerFilter || typeof pointerFilter !== "object") {
throw new Error("Invalid video pointer supplied for view lookup.");
}
const signal = options?.signal;
const normalizeAbortError = () => {
if (signal?.reason instanceof Error) {
return signal.reason;
}
if (typeof DOMException === "function") {
return new DOMException("Operation aborted", "AbortError");
}
const error = new Error("Operation aborted");
error.name = "AbortError";
return error;
};
if (signal?.aborted) {
throw normalizeAbortError();
}
try {
const result = await client.countEventsAcrossRelays([pointerFilter], {
relays: sanitizeRelayList(relayList, client.relays),
timeoutMs: options?.timeoutMs,
});
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
return { ...result, fallback: false };
}
} catch (error) {
if (error?.code !== "count-unsupported") {
logViewCountFailure(error);
}
}
if (signal?.aborted) {
throw normalizeAbortError();
}
const abortPromise =
signal &&
typeof signal === "object" &&
typeof signal.addEventListener === "function"
? new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
reject(normalizeAbortError());
},
{ once: true }
);
})
: null;
const listPromise = listVideoViewEvents(client, pointer, {
...fallbackListOptions,
});
const events = abortPromise
? await Promise.race([listPromise, abortPromise])
: await listPromise;
const uniqueCount = Array.isArray(events)
? (() => {
const withIds = events
.filter((event) => event && typeof event.id === "string")
.map((event) => event.id);
if (withIds.length === 0) {
return events.length;
}
return new Set(withIds).size;
})()
: 0;
return {
total: uniqueCount,
perRelay: [],
best: null,
fallback: true,
};
}
let ingestLocalViewEventRef = null;
async function loadIngestLocalViewEvent() {
if (typeof ingestLocalViewEventRef === "function") {
return ingestLocalViewEventRef;
}
try {
const module = await import("../viewCounter.js");
if (typeof module?.ingestLocalViewEvent === "function") {
ingestLocalViewEventRef = module.ingestLocalViewEvent;
return ingestLocalViewEventRef;
}
} catch (error) {
devLogger.warn("[nostr] Failed to load view counter ingest helper:", error);
}
return null;
}
export async function publishViewEvent(
client,
videoPointer,
options = {},
{
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
}
) {
if (!client?.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const pointer = normalizePointerInput(videoPointer);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const actorPubkey = await client.ensureSessionActor();
// actorPubkey derived via 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 guardScope = deriveViewEventPointerScope(pointer);
const guardBucket = deriveViewEventBucketIndex(createdAt);
if (guardScope && hasRecentViewPublish(guardScope, guardBucket, actorPubkey)) {
devLogger.info("[nostr] Skipping duplicate view publish for scope", guardScope);
return {
ok: true,
duplicate: true,
event: null,
results: [],
acceptedRelays: [],
};
}
const normalizedActor =
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
const normalizedLogged =
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
const usingSessionActor =
normalizedActor && normalizedActor !== normalizedLogged;
const additionalTags = sanitizeAdditionalTags(options.additionalTags);
const pointerTag =
pointer.type === "a"
? pointer.relay
? ["a", pointer.value, pointer.relay]
: ["a", pointer.value]
: pointer.relay
? ["e", pointer.value, pointer.relay]
: ["e", pointer.value];
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 custom view event content:", error);
content = "";
}
}
if (!content) {
const payload = {
target: {
type: pointer.type,
value: pointer.value,
},
created_at: createdAt,
};
if (pointer.relay) {
payload.target.relay = pointer.relay;
}
try {
content = JSON.stringify(payload);
} catch (error) {
devLogger.warn("[nostr] Failed to serialize default view event content:", error);
content = "";
}
}
const event = buildViewEvent({
pubkey: actorPubkey,
created_at: createdAt,
pointerValue: pointer.value,
pointerTag,
dedupeTag: generateViewEventDedupeTag(actorPubkey, pointer, createdAt),
includeSessionTag: usingSessionActor,
additionalTags,
content,
});
let signedEvent = null;
const signer = resolveActiveSigner(actorPubkey);
const canUseActiveSigner =
normalizedActor &&
normalizedActor === normalizedLogged &&
signer &&
typeof signer.signEvent === "function";
if (canUseActiveSigner) {
let permissionResult = { ok: true };
if (shouldRequestExtensionPermissions(signer)) {
permissionResult = await client.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS
);
}
if (permissionResult.ok) {
try {
signedEvent = await queueSignEvent(signer, event, {
timeoutMs: options?.timeoutMs,
});
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign view event with active signer:",
error,
);
return { ok: false, error: "signing-failed", details: error };
}
} else {
userLogger.warn(
"[nostr] Active signer permissions missing; signing view event with session key.",
permissionResult.error,
);
}
}
if (!signedEvent) {
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");
}
const privateKey = client.sessionActor.privateKey;
signedEvent = signEventWithPrivateKey(event, privateKey);
} catch (error) {
userLogger.warn("[nostr] Failed to sign view 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) {
if (guardScope) {
rememberViewPublish(guardScope, guardBucket, actorPubkey);
}
devLogger.info(
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
acceptedRelays.join(", ")
);
} else {
userLogger.warn("[nostr] View event rejected by relays:", publishResults);
}
return {
ok: success,
event: signedEvent,
results: publishResults,
acceptedRelays,
};
}
export async function recordVideoView(
client,
videoPointer,
options = {},
helpers
) {
const pointer = normalizePointerInput(videoPointer);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const view = await publishViewEvent(client, pointer, options, helpers);
if (view?.ok && view.event) {
try {
const ingest = await loadIngestLocalViewEvent();
if (typeof ingest === "function") {
ingest({ event: view.event, pointer });
}
} catch (error) {
devLogger.warn("[nostr] Failed to ingest optimistic view event:", error);
}
}
return view;
}
export const __testExports = {
resolveVideoViewPointer,
generateViewEventEntropy,
generateViewEventDedupeTag,
isVideoViewEvent,
loadIngestLocalViewEvent,
};