Files
bitvid/js/nostr.js
T

6779 lines
188 KiB
JavaScript

// js/nostr.js
import {
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
VIEW_COUNT_BACKFILL_MAX_DAYS,
} from "./config.js";
import {
ACCEPT_LEGACY_V1,
VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
} from "./constants.js";
import { accessControl } from "./accessControl.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
import { extractMagnetHints } from "./magnet.js";
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildViewEvent,
buildWatchHistoryIndexEvent,
buildWatchHistoryChunkEvent,
} from "./nostrEventSchemas.js";
import {
publishEventToRelay,
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
/**
* The default relay set BitVid bootstraps with before loading a user's
* preferences.
*/
export const DEFAULT_RELAY_URLS = Object.freeze([
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://relay.primal.net",
"wss://relay.nostr.band",
]);
const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again.";
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER =
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase()
: "";
// Continue reading historical payloads from kind 30078 until operators retire it.
const LEGACY_WATCH_HISTORY_KINDS = Object.freeze([30078]);
const WATCH_HISTORY_READ_KINDS = Object.freeze(
Array.from(
new Set(
[WATCH_HISTORY_KIND, ...LEGACY_WATCH_HISTORY_KINDS]
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isFinite(value))
)
)
);
const WATCH_HISTORY_CHUNK_V2_PREFIX = (() => {
const rawIdentifier =
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
? WATCH_HISTORY_LIST_IDENTIFIER.trim()
: "";
if (rawIdentifier) {
const trimmed = rawIdentifier.replace(/\/+$/, "");
if (/[:/]index$/i.test(trimmed)) {
const base = trimmed.replace(/[:/]index$/i, "").replace(/\/+$/, "");
return base || "watch-history:v2";
}
if (/:v\d+$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}:v2`;
}
return "watch-history:v2";
})();
const LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => {
const rawIdentifier =
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
? WATCH_HISTORY_LIST_IDENTIFIER.trim()
: "";
const suffixes = [":index", "/index"];
for (const suffix of suffixes) {
if (rawIdentifier.toLowerCase().endsWith(suffix)) {
return rawIdentifier
.slice(0, rawIdentifier.length - suffix.length)
.replace(/\/+$/, "");
}
}
return rawIdentifier.replace(/\/+$/, "") || "watch-history";
})();
const WATCH_HISTORY_CHUNK_V2_PREFIX_LOWER = WATCH_HISTORY_CHUNK_V2_PREFIX.toLowerCase();
const LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER =
LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX
? LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX.toLowerCase()
: "";
const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => {
const identifiers = Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)
? WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS
: [];
const normalized = new Set(
identifiers
.map((value) =>
typeof value === "string" ? value.trim().toLowerCase() : ""
)
.filter(Boolean)
);
if (!normalized.size) {
normalized.add("watch-history");
}
return normalized;
})();
function isWatchHistoryKind(kind) {
const numericKind = Number.parseInt(kind, 10);
if (!Number.isFinite(numericKind)) {
return false;
}
return WATCH_HISTORY_READ_KINDS.includes(numericKind);
}
const viewEventPublishMemory = new Map();
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) {
if (isDevMode) {
console.warn(
"[nostr] Failed to load view counter ingest helper:",
error
);
}
}
return null;
}
function deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) {
const sanitizedPrefix = WATCH_HISTORY_CHUNK_V2_PREFIX.replace(/\/+$/, "");
const safeSnapshot =
typeof snapshotId === "string" && snapshotId ? snapshotId : "0";
const safeIndex = Number.isFinite(chunkIndex)
? Math.max(0, Math.floor(chunkIndex))
: 0;
return `${sanitizedPrefix}/${safeSnapshot}/${safeIndex}`;
}
function isIndexIdentifier(identifier) {
if (!identifier || typeof identifier !== "string") {
return false;
}
if (!WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
return false;
}
return identifier.trim().toLowerCase() === WATCH_HISTORY_INDEX_IDENTIFIER_LOWER;
}
function isLegacyHeadIdentifier(identifier) {
if (!identifier || typeof identifier !== "string") {
return false;
}
const normalized = identifier.trim().toLowerCase();
if (!normalized) {
return false;
}
return LEGACY_WATCH_HISTORY_IDENTIFIER_SET.has(normalized);
}
function isWatchHistoryChunkIdentifier(identifier) {
if (!identifier || typeof identifier !== "string") {
return false;
}
const normalized = identifier.trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized.startsWith(`${WATCH_HISTORY_CHUNK_V2_PREFIX_LOWER}/`)) {
return true;
}
if (
LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER &&
normalized.startsWith(
`${LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX_LOWER}/`
)
) {
return true;
}
return normalized.startsWith("watch-history/");
}
function extractSnapshotIdFromEvent(event) {
if (!event || typeof event !== "object") {
return "";
}
const tags = Array.isArray(event.tags) ? event.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "snapshot" && typeof tag[1] === "string") {
const snapshot = tag[1].trim();
if (snapshot) {
return snapshot;
}
}
}
if (typeof event.content === "string" && event.content.trim()) {
const trimmedContent = event.content.trim();
if (trimmedContent.startsWith("{") || trimmedContent.startsWith("[")) {
try {
const parsed = JSON.parse(trimmedContent);
if (parsed && typeof parsed === "object") {
const snapshot =
typeof parsed.snapshot === "string" ? parsed.snapshot.trim() : "";
if (snapshot) {
return snapshot;
}
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to parse snapshot from event content:",
error
);
}
}
}
}
if (typeof event.id === "string" && event.id) {
return event.id;
}
return "";
}
function getWatchHistoryEntrySnapshotId(entry) {
if (!entry || typeof entry !== "object") {
return "";
}
if (entry.pointerEvent) {
const pointerSnapshot = extractSnapshotIdFromEvent(entry.pointerEvent);
if (pointerSnapshot) {
return pointerSnapshot;
}
}
if (entry.chunkEvents instanceof Map) {
for (const evt of entry.chunkEvents.values()) {
const snapshot = extractSnapshotIdFromEvent(evt);
if (snapshot) {
return snapshot;
}
}
}
return "";
}
// To limit error spam
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
function withNip07Timeout(operation) {
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE));
}, NIP07_LOGIN_TIMEOUT_MS);
});
let operationResult;
try {
operationResult = operation();
} catch (err) {
if (timeoutId) {
clearTimeout(timeoutId);
}
throw err;
}
const operationPromise = Promise.resolve(operationResult);
return Promise.race([operationPromise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
const resolvedTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
? Math.floor(resolvedTimeout)
: 4000;
let timeoutId = null;
let settled = false;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
if (typeof onTimeout === "function") {
try {
onTimeout();
} catch (cleanupError) {
if (isDevMode) {
console.warn("[nostr] COUNT timeout cleanup failed:", cleanupError);
}
}
}
reject(new Error(message));
}, effectiveTimeout);
Promise.resolve(promise)
.then((value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(error);
});
});
}
function sanitizeRelayList(list) {
const seen = new Set();
const sanitized = [];
if (!Array.isArray(list)) {
return sanitized;
}
list.forEach((value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
if (!/^wss?:\/\//i.test(trimmed)) {
return;
}
const normalized = trimmed.replace(/\/+$/, "");
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
sanitized.push(normalized);
});
return sanitized;
}
function pointerKey(pointer) {
if (!pointer || typeof pointer !== "object") {
return "";
}
const type = pointer.type === "a" ? "a" : "e";
const value =
typeof pointer.value === "string" ? pointer.value.trim().toLowerCase() : "";
if (!value) {
return "";
}
return `${type}:${value}`;
}
function clonePointerItem(pointer) {
if (!pointer || typeof pointer !== "object") {
return null;
}
const type = pointer.type === "a" ? "a" : "e";
const value = typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return null;
}
const relay =
typeof pointer.relay === "string" && pointer.relay.trim()
? pointer.relay.trim()
: null;
const watchedAtRaw = Number.isFinite(pointer.watchedAt)
? pointer.watchedAt
: null;
const watchedAt =
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : null;
const cloned = { type, value, relay };
if (watchedAt !== null) {
cloned.watchedAt = watchedAt;
}
return cloned;
}
function normalizePointerTag(tag) {
if (!Array.isArray(tag) || tag.length < 2) {
return null;
}
const type = tag[0] === "a" ? "a" : tag[0] === "e" ? "e" : "";
if (!type) {
return null;
}
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!value) {
return null;
}
const relay =
tag.length > 2 && typeof tag[2] === "string" && tag[2].trim()
? tag[2].trim()
: null;
return { type, value, relay };
}
function normalizePointerInput(pointer) {
if (!pointer) {
return null;
}
if (Array.isArray(pointer)) {
return normalizePointerTag(pointer);
}
if (typeof pointer === "object") {
if (typeof pointer.type === "string" && typeof pointer.value === "string") {
return clonePointerItem(pointer);
}
if (Array.isArray(pointer.tag)) {
return normalizePointerTag(pointer.tag);
}
}
if (typeof pointer !== "string") {
return null;
}
const trimmed = pointer.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("naddr") || trimmed.startsWith("nevent")) {
try {
const decoder = window?.NostrTools?.nip19?.decode;
if (typeof decoder === "function") {
const decoded = decoder(trimmed);
if (decoded?.type === "naddr" && decoded.data) {
const { kind, pubkey, identifier, relays } = decoded.data;
if (
typeof kind === "number" &&
typeof pubkey === "string" &&
typeof identifier === "string"
) {
const relay =
Array.isArray(relays) && relays.length && typeof relays[0] === "string"
? relays[0]
: null;
return {
type: "a",
value: `${kind}:${pubkey}:${identifier}`,
relay,
};
}
}
if (decoded?.type === "nevent" && decoded.data) {
const { id, relays } = decoded.data;
if (typeof id === "string" && id.trim()) {
const relay =
Array.isArray(relays) && relays.length && typeof relays[0] === "string"
? relays[0]
: null;
return {
type: "e",
value: id.trim(),
relay,
};
}
}
}
} catch (err) {
if (isDevMode) {
console.warn(`[nostr] Failed to decode pointer ${trimmed}:`, err);
}
}
}
const type = trimmed.includes(":") ? "a" : "e";
return { type, value: trimmed, relay: null };
}
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;
}
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: WATCH_HISTORY_READ_KINDS,
"#t": ["view"],
};
if (resolved.type === "a") {
pointerFilter["#a"] = [resolved.value];
} else {
pointerFilter["#e"] = [resolved.value];
}
const filters = [pointerFilter];
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
filters.push({
kinds: WATCH_HISTORY_READ_KINDS,
"#t": ["view"],
"#video": [resolved.value],
});
}
return { pointer: resolved, filters };
}
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);
}
function getViewEventGuardWindowMs() {
const windowSeconds = Math.max(
1,
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
);
return windowSeconds * 1000;
}
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) {
if (isDevMode) {
console.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}`;
}
function hasRecentViewPublish(scope, bucketIndex) {
if (!scope || !Number.isFinite(bucketIndex)) {
return false;
}
const windowMs = getViewEventGuardWindowMs();
const now = Date.now();
const entry = viewEventPublishMemory.get(scope);
if (entry) {
const age = now - Number(entry.seenAt);
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
viewEventPublishMemory.delete(scope);
} else if (Number(entry.bucket) === bucketIndex) {
return true;
}
}
if (typeof localStorage === "undefined") {
return false;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
let rawValue = null;
try {
rawValue = localStorage.getItem(storageKey);
} catch (error) {
if (isDevMode) {
console.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) {
if (isDevMode) {
console.warn("[nostr] Failed to clear corrupt view guard entry:", error);
}
}
return false;
}
if (now - storedSeenAt >= windowMs) {
try {
localStorage.removeItem(storageKey);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to remove expired view guard entry:", error);
}
}
return false;
}
viewEventPublishMemory.set(scope, {
bucket: storedBucket,
seenAt: storedSeenAt,
});
return storedBucket === bucketIndex;
}
function rememberViewPublish(scope, bucketIndex) {
if (!scope || !Number.isFinite(bucketIndex)) {
return;
}
const now = Date.now();
const windowMs = getViewEventGuardWindowMs();
const entry = viewEventPublishMemory.get(scope);
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
viewEventPublishMemory.delete(scope);
}
viewEventPublishMemory.set(scope, {
bucket: bucketIndex,
seenAt: now,
});
if (typeof localStorage === "undefined") {
return;
}
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
try {
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to persist view guard entry:", error);
}
}
}
function isVideoViewEvent(event, pointer) {
if (!event || typeof event !== "object") {
return false;
}
if (!isWatchHistoryKind(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 label = typeof tag[0] === "string" ? tag[0] : "";
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!label) {
continue;
}
if (!hasViewTag && label === "t" && value === "view") {
hasViewTag = true;
continue;
}
if (matchesPointer || !pointerValueLower) {
continue;
}
if (
VIEW_FILTER_INCLUDE_LEGACY_VIDEO &&
label === "video" &&
value.toLowerCase() === pointerValueLower
) {
matchesPointer = true;
continue;
}
const pointerTag = normalizePointerTag(tag);
if (!pointerTag) {
continue;
}
const tagValueLower =
typeof pointerTag.value === "string"
? pointerTag.value.trim().toLowerCase()
: "";
if (!tagValueLower || tagValueLower !== pointerValueLower) {
continue;
}
if (pointerTag.type === pointerType) {
matchesPointer = true;
continue;
}
}
return hasViewTag && matchesPointer;
}
function extractPointerItemsFromEvent(event) {
if (!event || typeof event !== "object") {
return [];
}
const tags = Array.isArray(event.tags) ? event.tags : [];
const seen = new Set();
const items = [];
for (const tag of tags) {
const pointer = normalizePointerTag(tag);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
items.push(pointer);
}
return items;
}
function normalizePointersFromPayload(payload) {
const normalized = [];
const seen = new Set();
if (!payload || typeof payload !== "object") {
return normalized;
}
const source = Array.isArray(payload.items) ? payload.items : [];
for (const candidate of source) {
const pointer = normalizePointerInput(candidate);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
normalized.push(pointer);
}
return normalized;
}
function parseWatchHistoryPayload(plaintext) {
if (typeof plaintext !== "string") {
return {
version: 0,
items: [],
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
}
try {
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== "object") {
return {
version: 0,
items: [],
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
}
const version = Number.isFinite(parsed.version) ? parsed.version : 0;
const items = normalizePointersFromPayload(parsed);
const snapshot =
typeof parsed.snapshot === "string" ? parsed.snapshot : "";
const chunkIndex = Number.isFinite(parsed.chunkIndex)
? Math.max(0, Math.floor(parsed.chunkIndex))
: 0;
const totalChunks = Number.isFinite(parsed.totalChunks)
? Math.max(1, Math.floor(parsed.totalChunks))
: 1;
return { version, items, snapshot, chunkIndex, totalChunks };
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to parse watch history payload:", error);
}
return {
version: 0,
items: [],
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
}
}
function chunkWatchHistoryPayloadItems(payloadItems, snapshotId, maxBytes) {
const items = Array.isArray(payloadItems) ? payloadItems : [];
const safeMax = Math.max(128, Math.floor(maxBytes || 0));
const measurementLimit = Math.max(64, safeMax - 32);
const normalizedSnapshot =
typeof snapshotId === "string" ? snapshotId : "";
const chunks = [];
const skipped = [];
let current = [];
const estimateLength = (chunkItems, chunkIndex, totalGuess) =>
JSON.stringify({
version: 2,
snapshot: normalizedSnapshot,
chunkIndex,
totalChunks: totalGuess,
items: chunkItems,
}).length;
for (const item of items) {
if (!item || typeof item !== "object") {
continue;
}
if (!current.length) {
const candidate = [item];
const size = estimateLength(candidate, chunks.length, chunks.length + 1);
if (size <= measurementLimit) {
current = candidate;
} else {
skipped.push(item);
}
continue;
}
const chunkIndex = chunks.length;
const candidate = [...current, item];
const size = estimateLength(candidate, chunkIndex, chunkIndex + 1);
if (size <= measurementLimit) {
current = candidate;
continue;
}
chunks.push(current);
current = [];
const nextIndex = chunks.length;
const soloSize = estimateLength([item], nextIndex, nextIndex + 1);
if (soloSize <= measurementLimit) {
current = [item];
} else {
skipped.push(item);
}
}
if (current.length || chunks.length === 0) {
chunks.push(current);
}
let needsRebalance = true;
while (needsRebalance) {
needsRebalance = false;
for (let index = 0; index < chunks.length; index++) {
let chunkItems = chunks[index];
if (!Array.isArray(chunkItems)) {
chunks[index] = [];
chunkItems = chunks[index];
}
let payloadSize = JSON.stringify({
version: 2,
snapshot: normalizedSnapshot,
chunkIndex: index,
totalChunks: chunks.length,
items: chunkItems,
}).length;
while (chunkItems.length && payloadSize > safeMax) {
const overflow = chunkItems.pop();
if (!overflow) {
break;
}
if (!chunks[index + 1]) {
chunks[index + 1] = [];
}
chunks[index + 1].unshift(overflow);
needsRebalance = true;
chunkItems = chunks[index];
payloadSize = JSON.stringify({
version: 2,
snapshot: normalizedSnapshot,
chunkIndex: index,
totalChunks: chunks.length,
items: chunkItems,
}).length;
}
}
}
while (chunks.length > 1 && chunks[chunks.length - 1].length === 0) {
chunks.pop();
}
if (!chunks.length) {
chunks.push([]);
}
return { chunks, skipped };
}
function eventToAddressPointer(event) {
if (!event || typeof event !== "object") {
return "";
}
const kind = typeof event.kind === "number" ? event.kind : null;
const pubkey = typeof event.pubkey === "string" ? event.pubkey : "";
if (!kind || !pubkey) {
return "";
}
const tags = Array.isArray(event.tags) ? event.tags : [];
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") {
return `${kind}:${pubkey}:${tag[1]}`;
}
}
return "";
}
function signEventWithPrivateKey(event, privateKey) {
if (
!privateKey ||
typeof privateKey !== "string" ||
!window?.NostrTools?.getEventHash ||
typeof window.NostrTools.signEvent !== "function"
) {
throw new Error("Missing signing primitives");
}
const tags = Array.isArray(event.tags)
? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag))
: [];
const prepared = {
kind: event.kind,
pubkey: event.pubkey,
created_at: event.created_at,
tags,
content: typeof event.content === "string" ? event.content : "",
};
const id = window.NostrTools.getEventHash(prepared);
const sig = window.NostrTools.signEvent(prepared, privateKey);
return { ...prepared, id, sig };
}
function cloneEventForCache(event) {
if (!event || typeof event !== "object") {
return null;
}
const cloned = { ...event };
if (Array.isArray(event.tags)) {
cloned.tags = event.tags.map((tag) =>
Array.isArray(tag) ? [...tag] : tag
);
}
return cloned;
}
/**
* Example "encryption" that just reverses strings.
* In real usage, replace with actual crypto.
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
function decodeNpubToHex(npub) {
if (typeof npub !== "string" || !npub.trim()) {
return "";
}
if (
!window?.NostrTools?.nip19 ||
typeof window.NostrTools.nip19.decode !== "function"
) {
return "";
}
try {
const decoded = window.NostrTools.nip19.decode(npub.trim());
if (decoded?.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (error) {
if (isDevMode) {
console.warn(`[nostr] Failed to decode npub: ${npub}`, error);
}
}
return "";
}
const EXTENSION_MIME_MAP = {
mp4: "video/mp4",
m4v: "video/x-m4v",
webm: "video/webm",
mkv: "video/x-matroska",
mov: "video/quicktime",
avi: "video/x-msvideo",
ogv: "video/ogg",
ogg: "video/ogg",
m3u8: "application/x-mpegURL",
mpd: "application/dash+xml",
ts: "video/mp2t",
mpg: "video/mpeg",
mpeg: "video/mpeg",
flv: "video/x-flv",
"3gp": "video/3gpp",
};
function inferMimeTypeFromUrl(url) {
if (!url || typeof url !== "string") {
return "";
}
let pathname = "";
try {
const parsed = new URL(url);
pathname = parsed.pathname || "";
} catch (err) {
const sanitized = url.split("?")[0].split("#")[0];
pathname = sanitized || "";
}
const lastSegment = pathname.split("/").pop() || "";
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
if (!match) {
return "";
}
const extension = match[1].toLowerCase();
return EXTENSION_MIME_MAP[extension] || "";
}
/**
* Convert a raw Nostr event into Bitvid's canonical "video" object.
*
* The converter intentionally centralises all of the quirky legacy handling so
* that feed rendering, subscriptions, and deep links rely on the exact same
* rules. Any future regression around magnet-only posts or malformed JSON
* should be solved by updating this function (and its tests) instead of
* sprinkling ad-hoc checks elsewhere in the UI.
*
* Also accepts legacy (<v2) payloads when ACCEPT_LEGACY_V1 allows it.
*/
function convertEventToVideo(event = {}) {
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
const rawContent = typeof event.content === "string" ? event.content : "";
const tags = Array.isArray(event.tags) ? event.tags : [];
let parsedContent = {};
let parseError = null;
if (rawContent) {
try {
const parsed = JSON.parse(rawContent);
if (parsed && typeof parsed === "object") {
parsedContent = parsed;
}
} catch (err) {
parseError = err;
parsedContent = {};
}
}
const directUrl = safeTrim(parsedContent.url);
const directMagnetRaw = safeTrim(parsedContent.magnet);
const normalizeMagnetCandidate = (value) => {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.toLowerCase().startsWith("magnet:?")) {
return trimmed;
}
const extracted = magnetFromText(trimmed);
return extracted ? extracted.trim() : "";
};
let magnet = normalizeMagnetCandidate(directMagnetRaw);
let rawMagnet = magnet ? directMagnetRaw : "";
if (!magnet && ACCEPT_LEGACY_V1) {
const inlineMagnet = normalizeMagnetCandidate(rawContent);
if (inlineMagnet) {
magnet = inlineMagnet;
}
if (!magnet) {
outer: for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const key =
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
const startIndex = key === "magnet" ? 1 : 0;
for (let i = startIndex; i < tag.length; i += 1) {
const candidate = normalizeMagnetCandidate(tag[i]);
if (candidate) {
magnet = candidate;
break outer;
}
}
}
}
if (!magnet) {
const recoveredFromRaw = magnetFromText(rawContent);
if (recoveredFromRaw) {
magnet = safeTrim(recoveredFromRaw);
}
}
}
if (!rawMagnet && magnet) {
rawMagnet = magnet;
}
const url = directUrl;
if (!url && !magnet) {
return { id: event.id, invalid: true, reason: "missing playable source" };
}
const thumbnail = safeTrim(parsedContent.thumbnail);
const description = safeTrim(parsedContent.description);
const rawMode = safeTrim(parsedContent.mode);
const mode = rawMode || "live";
const deleted = parsedContent.deleted === true;
const isPrivate = parsedContent.isPrivate === true;
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
const wsField = safeTrim(parsedContent.ws);
const xsField = safeTrim(parsedContent.xs);
const enableComments =
parsedContent.enableComments === false ? false : true;
let infoHash = "";
const pushInfoHash = (candidate) => {
if (typeof candidate !== "string") {
return false;
}
const normalized = candidate.trim().toLowerCase();
if (/^[0-9a-f]{40}$/.test(normalized)) {
infoHash = normalized;
return true;
}
return false;
};
pushInfoHash(parsedContent.infoHash);
if (!infoHash && magnet) {
const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i);
if (match && match[1]) {
pushInfoHash(match[1]);
}
}
const searchInfoHashInString = (value) => {
if (infoHash || typeof value !== "string") {
return;
}
const match = value.match(/[0-9a-f]{40}/i);
if (match && match[0]) {
pushInfoHash(match[0]);
}
};
if (!infoHash && ACCEPT_LEGACY_V1) {
searchInfoHashInString(rawContent);
for (const tag of tags) {
if (infoHash) {
break;
}
if (!Array.isArray(tag)) {
continue;
}
for (let i = 0; i < tag.length; i += 1) {
searchInfoHashInString(tag[i]);
if (infoHash) {
break;
}
}
}
}
const declaredTitle = safeTrim(parsedContent.title);
const derivedTitle = deriveTitleFromEvent({
parsedContent,
tags,
primaryTitle: declaredTitle,
});
let title = safeTrim(derivedTitle);
if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) {
title = infoHash
? `Legacy Video ${infoHash.slice(0, 8)}`
: "Legacy BitTorrent Video";
}
if (!title) {
const reason = parseError
? "missing title (json parse error)"
: "missing title";
return { id: event.id, invalid: true, reason };
}
const rawVersion = parsedContent.version;
let version = rawVersion === undefined ? 2 : Number(rawVersion);
if (!Number.isFinite(version)) {
version = rawVersion === undefined ? 2 : 1;
}
if (version < 2 && !ACCEPT_LEGACY_V1) {
return {
id: event.id,
invalid: true,
reason: `unsupported version ${version}`,
};
}
const magnetHints = magnet
? extractMagnetHints(magnet)
: { ws: "", xs: "" };
const ws = wsField || magnetHints.ws || "";
const xs = xsField || magnetHints.xs || "";
return {
id: event.id,
videoRootId,
version,
isPrivate,
title,
url,
magnet,
rawMagnet,
infoHash,
thumbnail,
description,
mode,
deleted,
ws,
xs,
enableComments,
pubkey: event.pubkey,
created_at: event.created_at,
tags,
invalid: false,
};
}
/**
* If the video has videoRootId => use that as the “group key”.
* Otherwise fallback to (pubkey + dTag), or if no dTag => “LEGACY:id”
*/
function getActiveKey(video) {
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
export { convertEventToVideo };
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
this.readRelays = Array.from(this.relays);
this.writeRelays = Array.from(this.relays);
// Store all events so older links still work
this.allEvents = new Map();
// “activeMap” holds only the newest version for each root
this.activeMap = new Map();
this.hasRestoredLocalData = false;
this.sessionActor = null;
this.watchHistoryCache = new Map();
this.watchHistoryStorage = null;
this.watchHistoryRepublishTimers = new Map();
this.watchHistoryRefreshPromises = new Map();
this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS;
this.countRequestCounter = 0;
this.countUnsupportedRelays = new Set();
}
makeCountUnsupportedError(relayUrl) {
const normalizedUrl =
typeof relayUrl === "string" && relayUrl.trim()
? relayUrl.trim()
: "";
const error = new Error(
`[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
);
error.code = "count-unsupported";
error.relay = normalizedUrl;
error.unsupported = true;
return error;
}
restoreLocalData() {
if (this.hasRestoredLocalData) {
return this.allEvents.size > 0;
}
this.hasRestoredLocalData = true;
if (typeof localStorage === "undefined") {
return false;
}
const now = Date.now();
const parsePayload = (raw) => {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to parse cached events:", err);
}
}
return null;
};
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
if (!payload) {
const legacyRaw = localStorage.getItem(LEGACY_EVENTS_STORAGE_KEY);
const legacyParsed = parsePayload(legacyRaw);
if (legacyParsed) {
payload = {
version: 1,
savedAt: now,
events: legacyParsed,
};
}
if (legacyRaw) {
try {
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to remove legacy cache:", err);
}
}
}
}
if (!payload || payload.version !== 1) {
return false;
}
if (
typeof payload.savedAt !== "number" ||
payload.savedAt <= 0 ||
now - payload.savedAt > EVENTS_CACHE_TTL_MS
) {
try {
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to clear expired cache:", err);
}
}
return false;
}
const events = payload.events;
if (!events || typeof events !== "object") {
return false;
}
this.allEvents.clear();
this.activeMap.clear();
for (const [id, video] of Object.entries(events)) {
if (!id || !video || typeof video !== "object") {
continue;
}
this.allEvents.set(id, video);
if (video.deleted) {
continue;
}
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
return this.allEvents.size > 0;
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays);
this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays);
}
/**
* Resolve the effective TTL for watch-history cache entries.
*/
getWatchHistoryCacheTtlMs() {
const candidate = this.watchHistoryCacheTtlMs;
if (Number.isFinite(candidate) && candidate >= 0) {
return candidate;
}
return WATCH_HISTORY_CACHE_TTL_MS;
}
/**
* Load watch history cache payload from localStorage.
*/
getWatchHistoryStorage() {
const base = { version: 1, actors: {} };
if (!this.watchHistoryStorage) {
if (typeof localStorage === "undefined") {
this.watchHistoryStorage = base;
return this.watchHistoryStorage;
}
let parsed = base;
try {
const raw = localStorage.getItem(WATCH_HISTORY_CACHE_STORAGE_KEY);
if (raw) {
const candidate = JSON.parse(raw);
if (
candidate &&
typeof candidate === "object" &&
candidate.version === 1 &&
candidate.actors &&
typeof candidate.actors === "object"
) {
parsed = { version: 1, actors: candidate.actors };
}
}
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to parse watch history cache:", error);
}
}
const actors = { ...(parsed.actors || {}) };
this.watchHistoryStorage = { version: 1, actors };
}
const now = Date.now();
const ttl = this.getWatchHistoryCacheTtlMs();
let mutated = false;
const actors = this.watchHistoryStorage.actors || {};
for (const actor of Object.keys(actors)) {
const info = actors[actor];
if (
!info ||
typeof info !== "object" ||
typeof info.savedAt !== "number" ||
now - info.savedAt > ttl
) {
delete actors[actor];
mutated = true;
}
}
if (mutated && typeof localStorage !== "undefined") {
try {
localStorage.setItem(
WATCH_HISTORY_CACHE_STORAGE_KEY,
JSON.stringify(this.watchHistoryStorage)
);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to persist cleaned watch history cache:",
error
);
}
}
}
return this.watchHistoryStorage;
}
createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) {
const normalizedItems = [];
const seen = new Set();
if (Array.isArray(items)) {
for (const raw of items) {
const pointer = normalizePointerInput(raw);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
const watchedAtRaw = Number.isFinite(pointer.watchedAt)
? pointer.watchedAt
: null;
const watchedAt =
watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0;
normalizedItems.push({ ...pointer, watchedAt });
}
}
const entry = {
pointerEvent: pointerEvent ? cloneEventForCache(pointerEvent) : null,
items: normalizedItems,
savedAt: typeof savedAt === "number" ? savedAt : Date.now(),
resolved: previousEntry?.resolved
? new Map(previousEntry.resolved)
: new Map(),
resolvedVideos: previousEntry?.resolvedVideos
? new Map(previousEntry.resolvedVideos)
: new Map(),
resolving: previousEntry?.resolving
? new Set(previousEntry.resolving)
: new Set(),
delivered: previousEntry?.delivered instanceof Set
? new Set(previousEntry.delivered)
: new Set(),
chunkNeedsSigner: previousEntry?.chunkNeedsSigner instanceof Set
? new Set(previousEntry.chunkNeedsSigner)
: new Set(),
};
const validKeys = new Set(entry.items.map((item) => pointerKey(item)));
for (const key of entry.resolved.keys()) {
if (!validKeys.has(key)) {
entry.resolved.delete(key);
}
}
for (const key of entry.resolvedVideos.keys()) {
if (!validKeys.has(key)) {
entry.resolvedVideos.delete(key);
}
}
for (const key of [...entry.resolving]) {
if (!validKeys.has(key)) {
entry.resolving.delete(key);
}
}
for (const key of [...entry.delivered]) {
if (!validKeys.has(key)) {
entry.delivered.delete(key);
}
}
for (const key of [...entry.chunkNeedsSigner]) {
if (!validKeys.has(key)) {
entry.chunkNeedsSigner.delete(key);
}
}
const previousChunkPointers = previousEntry?.chunkPointers;
const headPointer = previousChunkPointers?.head
? clonePointerItem(previousChunkPointers.head)
: null;
const chunkPointerList = Array.isArray(previousChunkPointers?.chunks)
? previousChunkPointers.chunks
.map((pointer) => clonePointerItem(pointer))
.filter(Boolean)
: [];
entry.chunkPointers = {
head: headPointer,
chunks: chunkPointerList,
};
entry.chunkEvents = new Map();
if (previousEntry?.chunkEvents instanceof Map) {
for (const [key, value] of previousEntry.chunkEvents.entries()) {
const clonedEvent = cloneEventForCache(value);
if (clonedEvent) {
entry.chunkEvents.set(key, clonedEvent);
}
}
}
return entry;
}
persistWatchHistoryEntry(actor, entry) {
if (typeof localStorage === "undefined" || !actor) {
return;
}
const storage = this.getWatchHistoryStorage();
storage.actors[actor] = {
pointerEvent: entry.pointerEvent ? cloneEventForCache(entry.pointerEvent) : null,
items: entry.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: Number.isFinite(item.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: 0,
})),
savedAt: entry.savedAt,
};
try {
localStorage.setItem(
WATCH_HISTORY_CACHE_STORAGE_KEY,
JSON.stringify(storage)
);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to persist watch history cache:", error);
}
}
}
getWatchHistoryFingerprint(pubkeyOrSession) {
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
? pubkeyOrSession.trim()
: "";
if (!actor) {
if (this.sessionActor?.pubkey) {
actor = this.sessionActor.pubkey.trim();
} else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
actor = this.pubkey.trim();
}
}
if (!actor) {
return "";
}
const entry = this.watchHistoryCache.get(actor);
if (!entry) {
return "";
}
const pointerId =
entry.pointerEvent && typeof entry.pointerEvent.id === "string"
? entry.pointerEvent.id
: "";
const pointerCreated =
entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at)
? entry.pointerEvent.created_at
: 0;
const itemKeys = entry.items
.map((item) => pointerKey(item))
.filter(Boolean)
.join("|");
return `${pointerId}:${pointerCreated}:${itemKeys}`;
}
resetWatchHistoryProgress(pubkeyOrSession = null) {
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
? pubkeyOrSession.trim()
: "";
if (!actor) {
if (this.sessionActor?.pubkey) {
actor = this.sessionActor.pubkey.trim();
} else if (typeof this.pubkey === "string" && this.pubkey.trim()) {
actor = this.pubkey.trim();
}
}
if (!actor) {
return;
}
const entry = this.watchHistoryCache.get(actor);
if (!entry) {
return;
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
} else {
entry.delivered.clear();
}
if (entry.resolving instanceof Set) {
entry.resolving.clear();
}
}
async encryptWatchHistoryPayload(actorPubkey, payload) {
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "";
if (!normalizedActor) {
return { ok: false, error: "invalid-actor" };
}
let normalizedPayload = payload;
if (!normalizedPayload || typeof normalizedPayload !== "object") {
normalizedPayload = {};
}
const items = Array.isArray(normalizedPayload.items)
? normalizedPayload.items
: [];
const version = Number.isFinite(normalizedPayload.version)
? normalizedPayload.version
: 1;
const prepared = {
version,
items,
};
if (version >= 2) {
prepared.snapshot =
typeof normalizedPayload.snapshot === "string"
? normalizedPayload.snapshot
: "";
prepared.chunkIndex = Number.isFinite(normalizedPayload.chunkIndex)
? Math.max(0, Math.floor(normalizedPayload.chunkIndex))
: 0;
prepared.totalChunks = Number.isFinite(normalizedPayload.totalChunks)
? Math.max(1, Math.floor(normalizedPayload.totalChunks))
: 1;
}
const plaintext = JSON.stringify(prepared);
let ciphertext = "";
const normalizedLogged =
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
if (
normalizedActor &&
normalizedActor === normalizedLogged &&
window?.nostr?.nip04?.encrypt
) {
try {
ciphertext = await window.nostr.nip04.encrypt(
normalizedActor,
plaintext
);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to encrypt watch history with extension:",
error
);
}
}
}
if (!ciphertext) {
try {
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
await this.ensureSessionActor(true);
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to ensure session actor while encrypting watch history:",
error
);
}
}
const privateKey = this.sessionActor?.privateKey;
if (privateKey && window?.NostrTools?.nip04?.encrypt) {
try {
ciphertext = await window.NostrTools.nip04.encrypt(
privateKey,
normalizedActor,
plaintext
);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to encrypt watch history with session key:",
error
);
}
}
}
}
if (!ciphertext) {
return { ok: false, error: "encryption-unavailable" };
}
return { ok: true, ciphertext };
}
async decryptWatchHistoryEvent(pointerEvent, actorPubkey) {
const fallbackItems = extractPointerItemsFromEvent(pointerEvent);
const fallbackPayload = {
version: 0,
items: fallbackItems,
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
const ciphertextRaw =
pointerEvent && typeof pointerEvent.content === "string"
? pointerEvent.content
: "";
const ciphertext = ciphertextRaw.trim();
if (!isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext)) {
return parseWatchHistoryContentWithFallback(
ciphertext,
fallbackItems,
fallbackPayload
);
}
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
: "";
if (!normalizedActor) {
return fallbackPayload;
}
if (!ciphertext) {
return fallbackPayload;
}
const normalizedLogged =
typeof this.pubkey === "string" && this.pubkey.trim()
? this.pubkey.trim().toLowerCase()
: "";
const pointerAuthor =
typeof pointerEvent?.pubkey === "string" && pointerEvent.pubkey.trim()
? pointerEvent.pubkey.trim().toLowerCase()
: "";
const hasExtensionDecrypt =
typeof window?.nostr?.nip04?.decrypt === "function";
const shouldDeferMissingSigner =
normalizedActor &&
normalizedActor === normalizedLogged &&
pointerAuthor &&
pointerAuthor === normalizedActor &&
!hasExtensionDecrypt;
if (shouldDeferMissingSigner) {
fallbackPayload.needsSignerRetry = true;
const warningMeta = {
actor: normalizedActor,
};
if (typeof pointerEvent?.id === "string" && pointerEvent.id) {
warningMeta.event = pointerEvent.id;
}
console.warn("[nostr] watch-history-decrypt-missing-signer", warningMeta);
return fallbackPayload;
}
if (
normalizedActor &&
normalizedActor === normalizedLogged &&
hasExtensionDecrypt
) {
try {
const plaintext = await window.nostr.nip04.decrypt(
normalizedActor,
ciphertext
);
return parseWatchHistoryContentWithFallback(
typeof plaintext === "string" ? plaintext.trim() : "",
fallbackItems,
fallbackPayload
);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to decrypt watch history with extension:",
error
);
}
}
}
try {
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
await this.ensureSessionActor(true);
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to ensure session actor while decrypting watch history:",
error
);
}
}
const privateKey = this.sessionActor?.privateKey;
if (privateKey && window?.NostrTools?.nip04?.decrypt) {
try {
const plaintext = await window.NostrTools.nip04.decrypt(
privateKey,
normalizedActor,
ciphertext
);
return parseWatchHistoryContentWithFallback(
typeof plaintext === "string" ? plaintext.trim() : "",
fallbackItems,
fallbackPayload
);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to decrypt watch history with session key:",
error
);
}
}
}
return fallbackPayload;
}
cancelWatchHistoryRepublish(actor) {
const key =
typeof actor === "string" && actor.trim()
? actor.trim().toLowerCase()
: "";
if (!key) {
return;
}
const timer = this.watchHistoryRepublishTimers.get(key);
if (timer) {
clearTimeout(timer);
this.watchHistoryRepublishTimers.delete(key);
}
}
scheduleWatchHistoryRepublish(actor, items) {
if (!this.pool) {
return;
}
const key =
typeof actor === "string" && actor.trim()
? actor.trim().toLowerCase()
: "";
if (!key || this.watchHistoryRepublishTimers.has(key)) {
return;
}
const clonedItems = Array.isArray(items)
? items
.map((item) => clonePointerItem(item))
.filter((candidate) => !!candidate)
: [];
if (!clonedItems.length) {
return;
}
const timer = setTimeout(() => {
this.watchHistoryRepublishTimers.delete(key);
this.publishWatchHistorySnapshot(actor, clonedItems, this.watchHistoryCache.get(actor) || null, {
allowRetry: false,
}).catch((error) => {
if (isDevMode) {
console.warn(
`[nostr] Failed to republish watch history list for ${key}:`,
error
);
}
});
}, 2000);
this.watchHistoryRepublishTimers.set(key, timer);
}
ensureWatchHistoryBackgroundRefresh(actor) {
const key =
typeof actor === "string" && actor.trim()
? actor.trim().toLowerCase()
: "";
if (!key) {
return;
}
if (!(this.watchHistoryRefreshPromises instanceof Map)) {
this.watchHistoryRefreshPromises = new Map();
}
if (this.watchHistoryRefreshPromises.has(key)) {
return;
}
const refreshPromise = this.fetchWatchHistory(actor, {
forceRefresh: true,
background: true,
})
.catch((error) => {
if (isDevMode) {
console.warn(
"[nostr] Background watch history refresh failed:",
error
);
}
})
.finally(() => {
this.watchHistoryRefreshPromises.delete(key);
});
this.watchHistoryRefreshPromises.set(key, refreshPromise);
}
async publishWatchHistorySnapshot(
actorPubkey,
candidateItems,
existingEntry = null,
options = {}
) {
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim()
: "";
if (!normalizedActor) {
return { ok: false, error: "missing-actor" };
}
const allowRetry = options?.allowRetry !== false;
const dedupe = new Map();
const normalizedItems = [];
const pushItem = (item) => {
const pointer = normalizePointerInput(item);
if (!pointer) {
return;
}
const key = pointerKey(pointer);
if (!key || dedupe.has(key)) {
return;
}
dedupe.set(key, true);
normalizedItems.push(pointer);
};
if (Array.isArray(candidateItems)) {
candidateItems.forEach((item) => pushItem(item));
} else if (candidateItems) {
pushItem(candidateItems);
}
const trimmedItems =
normalizedItems.length > WATCH_HISTORY_MAX_ITEMS
? normalizedItems.slice(0, WATCH_HISTORY_MAX_ITEMS)
: normalizedItems;
const payloadItems = trimmedItems.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: Number.isFinite(item.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: 0,
}));
const maxBytesCandidate = Number.isFinite(this.watchHistoryPayloadMaxBytes)
? this.watchHistoryPayloadMaxBytes
: WATCH_HISTORY_PAYLOAD_MAX_BYTES;
const maxPayloadBytes = Math.max(64, Math.floor(maxBytesCandidate));
const snapshotId = `${Math.floor(Date.now() / 1000)}-${Math.random()
.toString(36)
.slice(2, 10)}`;
const chunkInfo = chunkWatchHistoryPayloadItems(
payloadItems,
snapshotId,
maxPayloadBytes
);
const chunkItemsList = chunkInfo.chunks || [];
const skippedKeys = new Set(
(chunkInfo.skipped || [])
.map((item) => pointerKey(item))
.filter((key) => !!key)
);
const persistedItems = trimmedItems.filter((item) => {
const key = pointerKey(item);
return key && !skippedKeys.has(key);
});
const baselineEntry =
existingEntry || this.watchHistoryCache.get(normalizedActor) || null;
const persistSnapshotToCache = () => {
const pointerForCache = baselineEntry?.pointerEvent || null;
const entry = this.createWatchHistoryEntry(
pointerForCache,
persistedItems,
Date.now(),
baselineEntry
);
this.watchHistoryCache.set(normalizedActor, entry);
this.persistWatchHistoryEntry(normalizedActor, entry);
return entry;
};
if (chunkInfo.skipped?.length && isDevMode) {
console.warn(
`[nostr] Skipped ${chunkInfo.skipped.length} oversized watch history entries while chunking.`
);
}
const relays =
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
const normalizedLogged =
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
const normalizedActorLower = normalizedActor.toLowerCase();
const useExtension =
normalizedActorLower === normalizedLogged &&
window?.nostr &&
typeof window.nostr.signEvent === "function";
if (!useExtension) {
if (
normalizedActorLower === normalizedLogged &&
(!window?.nostr || typeof window.nostr.signEvent !== "function")
) {
const cachedEntry = persistSnapshotToCache();
if (allowRetry && persistedItems.length) {
this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems);
}
return {
ok: false,
error: "signing-unavailable",
snapshot: snapshotId,
items: cachedEntry.items
.map((item) => clonePointerItem(item))
.filter(Boolean),
};
}
try {
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
await this.ensureSessionActor(true);
}
if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) {
throw new Error("session-actor-mismatch");
}
} catch (error) {
console.warn(
"[nostr] Failed to prepare signing key for watch history chunks:",
error
);
return { ok: false, error: "signing-failed", details: error };
}
}
const totalChunks = chunkItemsList.length || 1;
const chunkIdentifiers = chunkItemsList.map((_, index) =>
deriveWatchHistoryChunkIdentifier(snapshotId, index)
);
const chunkAddresses = chunkIdentifiers.map(
(identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}`
);
const nowSeconds = Math.floor(Date.now() / 1000);
let lastCreatedAt = 0;
const pointerCandidates = [];
if (existingEntry?.pointerEvent) {
pointerCandidates.push(existingEntry.pointerEvent);
}
const cachedPointerEvent = this.watchHistoryCache.get(normalizedActor)?.pointerEvent;
if (cachedPointerEvent) {
pointerCandidates.push(cachedPointerEvent);
}
for (const candidate of pointerCandidates) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const createdAt = Number.isFinite(candidate.created_at)
? Math.floor(candidate.created_at)
: null;
if (createdAt !== null && createdAt > lastCreatedAt) {
lastCreatedAt = createdAt;
}
}
const baseTimestamp = Math.max(nowSeconds, lastCreatedAt + totalChunks);
const preparedChunks = [];
for (let index = 0; index < totalChunks; index++) {
const chunkItems = Array.isArray(chunkItemsList[index])
? chunkItemsList[index]
: [];
const payload = {
version: 2,
snapshot: snapshotId,
chunkIndex: index,
totalChunks,
items: chunkItems,
};
const encryptionResult = await this.encryptWatchHistoryPayload(
normalizedActor,
payload
);
if (!encryptionResult.ok) {
if (isDevMode) {
console.warn(
"[nostr] Unable to encrypt watch history chunk:",
encryptionResult.error
);
}
return encryptionResult;
}
const pointerTags = chunkItems.map((item) => {
const tag = [item.type, item.value];
if (item.relay) {
tag.push(item.relay);
}
return tag;
});
const chunkIdentifier =
chunkIdentifiers[index] ?? deriveWatchHistoryChunkIdentifier(snapshotId, index);
// Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots
// even when multiple publishes land within the same wall-clock second.
const event = buildWatchHistoryChunkEvent({
pubkey: normalizedActor,
created_at: baseTimestamp + (totalChunks - index - 1),
chunkIdentifier,
snapshotId,
chunkIndex: index,
totalChunks,
pointerTags,
chunkAddresses,
content: encryptionResult.ciphertext,
});
let signedEvent;
if (useExtension) {
try {
signedEvent = await window.nostr.signEvent(event);
} catch (error) {
console.warn(
"[nostr] Failed to sign watch history chunk with extension:",
error
);
return { ok: false, error: "signing-failed", details: error };
}
} else {
try {
signedEvent = signEventWithPrivateKey(
event,
this.sessionActor.privateKey
);
} catch (error) {
console.warn(
"[nostr] Failed to sign watch history chunk with session key:",
error
);
return { ok: false, error: "signing-failed", details: error };
}
}
preparedChunks.push({
chunkIndex: index,
event: signedEvent,
});
}
const chunkResults = [];
let overallSuccess = true;
for (const chunk of preparedChunks) {
const publishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.pool, url, chunk.event))
);
const chunkSuccess = publishResults.some((result) => result.success);
if (!chunkSuccess) {
overallSuccess = false;
console.warn(
"[nostr] Failed to publish watch history chunk:",
publishResults
);
}
chunkResults.push({
chunkIndex: chunk.chunkIndex,
event: chunk.event,
results: publishResults,
success: chunkSuccess,
});
}
const allChunksSucceeded = chunkResults.every((chunk) => chunk.success);
let signedIndexEvent = null;
let indexPublishResults = [];
let indexSuccess = false;
if (allChunksSucceeded) {
const indexEvent = buildWatchHistoryIndexEvent({
pubkey: normalizedActor,
created_at: baseTimestamp + totalChunks,
snapshotId,
totalChunks,
chunkAddresses,
});
try {
if (useExtension) {
signedIndexEvent = await window.nostr.signEvent(indexEvent);
} else {
signedIndexEvent = signEventWithPrivateKey(
indexEvent,
this.sessionActor.privateKey
);
}
} catch (error) {
console.warn("[nostr] Failed to sign watch history index event:", error);
signedIndexEvent = null;
}
if (signedIndexEvent) {
indexPublishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.pool, url, signedIndexEvent))
);
indexSuccess = indexPublishResults.some((result) => result.success);
if (!indexSuccess) {
overallSuccess = false;
console.warn(
"[nostr] Failed to publish watch history index:",
indexPublishResults
);
signedIndexEvent = null;
}
} else {
overallSuccess = false;
}
} else {
overallSuccess = false;
}
const publishAttemptSummaries = [];
const acceptedRelaySet = new Set();
const recordPublishAttempt = (label, results) => {
if (!Array.isArray(results) || !results.length) {
return;
}
const accepted = results
.filter((result) => result && result.success)
.map((result) => result.url)
.filter((url) => typeof url === "string" && url);
if (accepted.length) {
accepted.forEach((url) => acceptedRelaySet.add(url));
}
publishAttemptSummaries.push({ label, accepted, results });
};
chunkResults.forEach((chunk) => {
recordPublishAttempt(`chunk#${chunk.chunkIndex}`, chunk.results);
});
recordPublishAttempt("index", indexPublishResults);
const failedAttempts = publishAttemptSummaries.filter(
(attempt) => !attempt.accepted.length
);
if (acceptedRelaySet.size) {
console.info(
`[nostr] Watch history publish accepted by ${acceptedRelaySet.size} relay(s):`,
Array.from(acceptedRelaySet).join(", ")
);
}
if (failedAttempts.length) {
console.warn(
"[nostr] Watch history publish rejected by relays:",
failedAttempts.map((attempt) => ({
label: attempt.label,
results: attempt.results,
}))
);
} else if (!acceptedRelaySet.size && publishAttemptSummaries.length) {
console.warn("[nostr] Watch history publish had no relay attempts.");
}
if (overallSuccess) {
this.cancelWatchHistoryRepublish(normalizedActor);
} else if (allowRetry && persistedItems.length) {
this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems);
}
const latestItem = persistedItems[0] || null;
const latestPointerKey = pointerKey(latestItem);
const latestPointerSummary = latestPointerKey
? latestPointerKey.length > 32
? `${latestPointerKey.slice(0, 32)}…`
: latestPointerKey
: null;
const actorPreview =
normalizedActor.length > 16
? `${normalizedActor.slice(0, 8)}…${normalizedActor.slice(-4)}`
: normalizedActor;
const summary = {
actor: actorPreview,
itemCount: persistedItems.length,
snapshot: snapshotId,
latestPointer: latestPointerSummary,
latestWatchedAt: Number.isFinite(latestItem?.watchedAt)
? new Date(Math.max(0, Math.floor(latestItem.watchedAt))).toISOString()
: null,
};
if (overallSuccess) {
console.info("[nostr] Watch history snapshot updated:", summary);
} else {
console.warn("[nostr] Watch history snapshot publish incomplete:", summary);
}
const pointerEvent =
signedIndexEvent ||
chunkResults.find((chunk) => chunk.success)?.event ||
chunkResults[0]?.event ||
null;
const newEntry = this.createWatchHistoryEntry(
pointerEvent,
persistedItems,
Date.now(),
baselineEntry
);
if (!(newEntry.chunkEvents instanceof Map)) {
newEntry.chunkEvents = new Map();
} else {
newEntry.chunkEvents.clear();
}
const chunkPointerItems = [];
const chunkPointerKeys = new Set();
chunkResults.forEach((chunk) => {
const address = eventToAddressPointer(chunk.event);
if (!address) {
return;
}
const pointer = clonePointerItem({ type: "a", value: address, relay: null });
if (!pointer) {
return;
}
const key = pointerKey(pointer);
if (!key || chunkPointerKeys.has(key)) {
return;
}
chunkPointerKeys.add(key);
chunkPointerItems.push(pointer);
const clonedChunk = cloneEventForCache(chunk.event);
newEntry.chunkEvents.set(key, clonedChunk || chunk.event);
});
let indexPointer = null;
if (signedIndexEvent) {
const indexAddress = eventToAddressPointer(signedIndexEvent);
if (indexAddress) {
const pointer = clonePointerItem({
type: "a",
value: indexAddress,
relay: null,
});
if (pointer) {
const key = pointerKey(pointer);
if (key) {
chunkPointerKeys.add(key);
const clonedIndex = cloneEventForCache(signedIndexEvent);
newEntry.chunkEvents.set(key, clonedIndex || signedIndexEvent);
}
indexPointer = pointer;
}
}
}
newEntry.chunkPointers = {
head: indexPointer || chunkPointerItems[0] || null,
index: indexPointer,
chunks: chunkPointerItems,
};
this.watchHistoryCache.set(normalizedActor, newEntry);
this.persistWatchHistoryEntry(normalizedActor, newEntry);
const combinedEvents = [];
const combinedResults = [];
if (signedIndexEvent) {
combinedEvents.push(signedIndexEvent);
combinedResults.push(indexPublishResults);
}
chunkResults.forEach((chunk) => {
combinedEvents.push(chunk.event);
combinedResults.push(chunk.results);
});
return {
ok: overallSuccess,
event: pointerEvent,
indexEvent: signedIndexEvent,
events: combinedEvents,
chunks: chunkResults,
results: combinedResults,
indexResults: indexPublishResults,
snapshot: snapshotId,
items: newEntry.items
.map((item) => clonePointerItem(item))
.filter(Boolean),
};
}
async ensureSessionActor(forceSession = false) {
const loggedInPubkey =
typeof this.pubkey === "string" && this.pubkey.trim()
? this.pubkey.trim()
: "";
if (!forceSession && loggedInPubkey) {
return loggedInPubkey;
}
if (
this.sessionActor &&
typeof this.sessionActor.pubkey === "string" &&
this.sessionActor.pubkey
) {
return this.sessionActor.pubkey;
}
if (typeof localStorage !== "undefined") {
try {
const raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.pubkey === "string" &&
parsed.pubkey &&
typeof parsed.privkey === "string" &&
parsed.privkey
) {
this.sessionActor = {
pubkey: parsed.pubkey,
privateKey: parsed.privkey,
};
return this.sessionActor.pubkey;
}
}
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to restore session actor:", error);
}
}
}
const nostrTools = window?.NostrTools || {};
const keyGenerator =
typeof nostrTools.generatePrivateKey === "function"
? nostrTools.generatePrivateKey
: typeof nostrTools.generateSecretKey === "function"
? nostrTools.generateSecretKey
: null;
const pubkeyDeriver =
typeof nostrTools.getPublicKey === "function"
? nostrTools.getPublicKey
: null;
if (!keyGenerator || !pubkeyDeriver) {
if (isDevMode) {
console.warn(
"[nostr] Unable to generate session actor: missing NostrTools helpers."
);
}
return "";
}
let privateKey = keyGenerator();
if (privateKey && typeof privateKey !== "string") {
const hasLength =
typeof privateKey === "object" &&
privateKey !== null &&
typeof privateKey.length === "number";
if (nostrTools.utils?.bytesToHex && hasLength) {
try {
privateKey = nostrTools.utils.bytesToHex(privateKey);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to normalize generated private key:",
error
);
}
privateKey = "";
}
} else {
privateKey = "";
}
}
if (typeof privateKey !== "string" || !privateKey) {
if (isDevMode) {
console.warn(
"[nostr] Unable to generate session actor: invalid private key output."
);
}
return "";
}
let pubkey = pubkeyDeriver(privateKey);
if (pubkey && typeof pubkey !== "string") {
const hasLength =
typeof pubkey === "object" &&
pubkey !== null &&
typeof pubkey.length === "number";
if (nostrTools.utils?.bytesToHex && hasLength) {
try {
pubkey = nostrTools.utils.bytesToHex(pubkey);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to normalize generated public key:",
error
);
}
pubkey = "";
}
} else {
pubkey = "";
}
}
if (typeof pubkey !== "string" || !pubkey) {
if (isDevMode) {
console.warn(
"[nostr] Unable to generate session actor: invalid public key output."
);
}
return "";
}
this.sessionActor = { pubkey, privateKey };
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
JSON.stringify({ pubkey, privkey: privateKey, savedAt: Date.now() })
);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to persist session actor:", error);
}
}
}
return pubkey;
}
async publishViewEvent(videoPointer, options = {}) {
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const pointer = normalizePointerInput(videoPointer);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const actorPubkey = await this.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)) {
if (isDevMode) {
console.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 this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
const usingSessionActor =
normalizedActor && normalizedActor !== normalizedLogged;
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter(
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
)
: [];
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) {
if (isDevMode) {
console.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) {
if (isDevMode) {
console.warn(
"[nostr] Failed to serialize default view event content:",
error
);
}
content = "";
}
}
const dedupeTag = generateViewEventDedupeTag(actorPubkey, pointer, createdAt);
const event = buildViewEvent({
pubkey: actorPubkey,
created_at: createdAt,
pointerValue: pointer.value,
pointerTag,
includeSessionTag: usingSessionActor,
additionalTags,
dedupeTag,
content,
});
let signedEvent = null;
if (
normalizedActor &&
normalizedActor === normalizedLogged &&
window?.nostr &&
typeof window.nostr.signEvent === "function"
) {
try {
signedEvent = await window.nostr.signEvent(event);
} catch (error) {
console.warn("[nostr] Failed to sign view event with extension:", error);
return { ok: false, error: "signing-failed", details: error };
}
} else {
try {
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
await this.ensureSessionActor(true);
}
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
throw new Error("session-actor-mismatch");
}
const privateKey = this.sessionActor.privateKey;
signedEvent = signEventWithPrivateKey(event, privateKey);
} catch (error) {
console.warn("[nostr] Failed to sign view event with session key:", error);
return { ok: false, error: "signing-failed", details: error };
}
}
const relayList =
Array.isArray(options.relays) && options.relays.length
? options.relays
: this.relays;
const relays =
Array.isArray(relayList) && relayList.length ? relayList : RELAY_URLS;
const publishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.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);
}
console.info(
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
acceptedRelays.join(", ")
);
} else {
console.warn("[nostr] View event rejected by relays:", publishResults);
}
return {
ok: success,
event: signedEvent,
results: publishResults,
acceptedRelays,
};
}
async listVideoViewEvents(pointer, options = {}) {
if (!this.pool) {
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 = Array.isArray(relays) && relays.length
? relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
let rawResults;
try {
rawResults = await this.pool.list(relayList, filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to list video view events:", error);
}
return [];
}
const flattenResults = (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;
};
const flattened = flattenResults(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);
}
subscribeVideoViewEvents(pointer, options = {}) {
if (!this.pool) {
if (isDevMode) {
console.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 = Array.isArray(options?.relays) && options.relays.length
? options.relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
let subscription;
try {
subscription = this.pool.sub(relayList, filters);
} catch (error) {
if (isDevMode) {
console.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) {
if (isDevMode) {
console.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) {
if (isDevMode) {
console.warn(
"[nostr] Failed to unsubscribe from video view events:",
error
);
}
}
}
};
}
async countVideoViewEvents(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 secondsPerDay = 86_400;
const nowSeconds = Math.floor(Date.now() / 1000);
const fallbackSinceSeconds = Math.max(
0,
nowSeconds - horizonDays * secondsPerDay
);
listOptions.since = Math.floor(fallbackSinceSeconds);
}
}
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;
})();
if (!this.pool) {
const events = await this.listVideoViewEvents(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 this.countEventsAcrossRelays([pointerFilter], {
relays: relayList,
timeoutMs: options?.timeoutMs,
});
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
return { ...result, fallback: false };
}
} catch (error) {
if (isDevMode) {
console.warn("[nostr] COUNT view request failed:", 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 = this.listVideoViewEvents(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,
};
}
async updateWatchHistoryList(pointer) {
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const normalizedPointer = normalizePointerInput(pointer);
if (!normalizedPointer) {
return { ok: false, error: "invalid-pointer" };
}
if (Number.isFinite(normalizedPointer.watchedAt)) {
normalizedPointer.watchedAt = Math.max(
0,
Math.floor(normalizedPointer.watchedAt)
);
} else {
normalizedPointer.watchedAt = Date.now();
}
const actorPubkey = await this.ensureSessionActor();
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
await this.fetchWatchHistory(actorPubkey);
const existingEntry =
this.watchHistoryCache.get(actorPubkey) ||
this.createWatchHistoryEntry(null, [], Date.now());
const candidates = [normalizedPointer, ...(existingEntry.items || [])];
return this.publishWatchHistorySnapshot(actorPubkey, candidates, existingEntry);
}
async removeWatchHistoryItem(pointerOrKey) {
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const actorPubkey = await this.ensureSessionActor();
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
await this.fetchWatchHistory(actorPubkey);
const entry = this.watchHistoryCache.get(actorPubkey);
if (!entry) {
return { ok: false, error: "missing-entry" };
}
let targetKey = "";
if (typeof pointerOrKey === "string" && pointerOrKey.trim()) {
targetKey = pointerOrKey.trim().toLowerCase();
} else if (pointerOrKey) {
const normalizedPointer = normalizePointerInput(pointerOrKey);
targetKey = pointerKey(normalizedPointer);
}
if (!targetKey) {
return { ok: false, error: "invalid-pointer" };
}
const filteredItems = entry.items.filter((item) => {
const key = pointerKey(item);
return key && key !== targetKey;
});
if (filteredItems.length === entry.items.length) {
return { ok: false, error: "pointer-not-found" };
}
const result = await this.publishWatchHistorySnapshot(
actorPubkey,
filteredItems,
entry
);
if (!result.ok) {
return result;
}
return { ...result, removedKey: targetKey };
}
async fetchWatchHistory(pubkeyOrSession, options = {}) {
const forceRefresh = options?.forceRefresh === true;
const background = options?.background === true;
let actor =
typeof pubkeyOrSession === "string" && pubkeyOrSession.trim()
? pubkeyOrSession.trim()
: "";
if (!actor) {
actor = await this.ensureSessionActor();
}
if (!actor) {
return { pointerEvent: null, items: [] };
}
const now = Date.now();
const ttl = this.getWatchHistoryCacheTtlMs();
const cached = this.watchHistoryCache.get(actor);
const priorEntry = cached || null;
if (!forceRefresh && cached) {
if (now - cached.savedAt < ttl) {
if (!background) {
this.ensureWatchHistoryBackgroundRefresh(actor);
}
return {
pointerEvent: cached.pointerEvent
? cloneEventForCache(cached.pointerEvent)
: null,
items: cached.items
.map((item) => clonePointerItem(item))
.filter(Boolean),
};
}
this.watchHistoryCache.delete(actor);
}
let storage = null;
let stored = null;
if (typeof localStorage !== "undefined") {
storage = this.getWatchHistoryStorage();
stored = storage.actors?.[actor];
if (
!forceRefresh &&
stored &&
typeof stored.savedAt === "number" &&
now - stored.savedAt < ttl
) {
const entry = this.createWatchHistoryEntry(
stored.pointerEvent,
Array.isArray(stored.items) ? stored.items : [],
stored.savedAt,
this.watchHistoryCache.get(actor) || null
);
this.watchHistoryCache.set(actor, entry);
if (!background) {
this.ensureWatchHistoryBackgroundRefresh(actor);
}
return {
pointerEvent: entry.pointerEvent
? cloneEventForCache(entry.pointerEvent)
: null,
items: entry.items
.map((item) => clonePointerItem(item))
.filter(Boolean),
};
}
}
const cachedSnapshotId = getWatchHistoryEntrySnapshotId(priorEntry);
let snapshotChanged = false;
if (!this.pool) {
const fallbackEntry = this.createWatchHistoryEntry(
null,
[],
Date.now(),
priorEntry
);
this.watchHistoryCache.set(actor, fallbackEntry);
return { pointerEvent: null, items: [] };
}
const relayList =
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
const fetchLimitCandidate = Number.isFinite(this.watchHistoryFetchEventLimit)
? this.watchHistoryFetchEventLimit
: WATCH_HISTORY_FETCH_EVENT_LIMIT;
const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate));
const indexIdentifier =
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" &&
WATCH_HISTORY_LIST_IDENTIFIER.trim()
? WATCH_HISTORY_LIST_IDENTIFIER.trim()
: "";
const indexIdentifiers = indexIdentifier ? [indexIdentifier] : [];
const legacyIdentifierSet = new Set();
if (Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)) {
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS.forEach((value) => {
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
if (normalized && normalized !== indexIdentifier) {
legacyIdentifierSet.add(normalized);
}
}
});
}
if (!indexIdentifiers.length && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
indexIdentifiers.push(WATCH_HISTORY_LIST_IDENTIFIER);
}
const headEventMap = new Map();
const registerHeadEvent = (evt) => {
if (!evt || typeof evt !== "object") {
return;
}
const id = typeof evt.id === "string" ? evt.id : null;
if (!id) {
return;
}
const createdAt = typeof evt.created_at === "number" ? evt.created_at : 0;
const existing = headEventMap.get(id);
if (
!existing ||
createdAt >
(typeof existing?.created_at === "number" ? existing.created_at : 0)
) {
headEventMap.set(id, evt);
}
};
if (indexIdentifiers.length) {
const indexFilter = {
kinds: WATCH_HISTORY_READ_KINDS,
authors: [actor],
limit: fetchLimit,
"#d": indexIdentifiers,
};
try {
const events = await this.pool.list(relayList, [indexFilter]);
if (Array.isArray(events)) {
events.forEach(registerHeadEvent);
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch watch history index events:",
error
);
}
}
}
if (legacyIdentifierSet.size) {
const legacyFilter = {
kinds: WATCH_HISTORY_READ_KINDS,
authors: [actor],
limit: fetchLimit,
"#d": Array.from(legacyIdentifierSet),
};
try {
const events = await this.pool.list(relayList, [legacyFilter]);
if (Array.isArray(events)) {
events.forEach(registerHeadEvent);
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch legacy watch history pointers:",
error
);
}
}
}
const fetchedHeadEvents = Array.from(headEventMap.values());
fetchedHeadEvents.sort((a, b) => {
const aCreated = typeof a?.created_at === "number" ? a.created_at : 0;
const bCreated = typeof b?.created_at === "number" ? b.created_at : 0;
return bCreated - aCreated;
});
const chunkIdentifiers = new Set();
const snapshotIds = new Set();
const indexSnapshots = new Map();
for (const event of fetchedHeadEvents) {
if (!event || typeof event !== "object") {
continue;
}
const tags = Array.isArray(event.tags) ? event.tags : [];
let identifierTagValue = "";
let snapshotTagValue = "";
let totalChunksTagValue = null;
const pointerIdentifiers = new Set();
const pointerAddresses = new Set();
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
identifierTagValue = tag[1];
} else if (tag[0] === "snapshot" && typeof tag[1] === "string" && tag[1]) {
snapshotTagValue = tag[1];
} else if (tag[0] === "chunks") {
const parsedTotal = Number.parseInt(tag[1], 10);
if (Number.isFinite(parsedTotal)) {
totalChunksTagValue = Math.max(0, parsedTotal);
}
} else if (tag[0] === "a") {
const pointer = normalizePointerTag(tag);
if (pointer?.type === "a" && typeof pointer.value === "string") {
pointerAddresses.add(pointer.value);
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number.parseInt(kindStr, 10);
if (
Number.isFinite(kind) &&
isWatchHistoryKind(kind) &&
typeof pubkey === "string" &&
pubkey === actor &&
typeof identifier === "string" &&
identifier &&
!isIndexIdentifier(identifier)
) {
pointerIdentifiers.add(identifier);
}
}
}
}
const identifier = typeof identifierTagValue === "string" ? identifierTagValue : "";
let snapshotId = snapshotTagValue;
let totalChunks =
Number.isFinite(totalChunksTagValue) && totalChunksTagValue !== null
? Math.max(0, Math.floor(totalChunksTagValue))
: null;
const isIndexEvent = isIndexIdentifier(identifier);
if (isIndexEvent && typeof event.content === "string") {
const trimmedContent = event.content.trim();
if (trimmedContent) {
const firstChar = trimmedContent[0];
if (firstChar === "{" || firstChar === "[") {
try {
const parsed = JSON.parse(trimmedContent);
if (
!snapshotId &&
typeof parsed?.snapshot === "string" &&
parsed.snapshot
) {
snapshotId = parsed.snapshot;
}
if (
totalChunks === null &&
Number.isFinite(Number.parseInt(parsed?.totalChunks, 10))
) {
totalChunks = Math.max(
0,
Math.floor(Number.parseInt(parsed.totalChunks, 10))
);
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to parse watch history index payload:",
error
);
}
}
} else if (!snapshotId) {
snapshotId = trimmedContent;
}
}
}
if (!snapshotId) {
snapshotId = typeof event.id === "string" && event.id ? event.id : "";
}
if (snapshotId) {
snapshotIds.add(snapshotId);
}
pointerIdentifiers.forEach((value) => {
if (value) {
chunkIdentifiers.add(value);
}
});
if (identifier && !isIndexEvent) {
chunkIdentifiers.add(identifier);
}
if (isIndexEvent && snapshotId) {
const existingMeta = indexSnapshots.get(snapshotId) || {
event: null,
totalChunks: null,
chunkIdentifiers: new Set(),
chunkAddresses: new Set(),
};
const existingCreatedAt =
typeof existingMeta.event?.created_at === "number"
? existingMeta.event.created_at
: 0;
const candidateCreatedAt =
typeof event.created_at === "number" ? event.created_at : 0;
if (!existingMeta.event || candidateCreatedAt >= existingCreatedAt) {
existingMeta.event = event;
}
if (Number.isFinite(totalChunks)) {
if (
!Number.isFinite(existingMeta.totalChunks) ||
totalChunks > existingMeta.totalChunks
) {
existingMeta.totalChunks = totalChunks;
}
}
pointerIdentifiers.forEach((value) => {
if (value) {
existingMeta.chunkIdentifiers.add(value);
}
});
pointerAddresses.forEach((value) => {
if (value) {
existingMeta.chunkAddresses.add(value);
}
});
indexSnapshots.set(snapshotId, existingMeta);
} else if (snapshotId) {
const existingMeta = indexSnapshots.get(snapshotId);
if (existingMeta) {
pointerIdentifiers.forEach((value) => {
if (value) {
existingMeta.chunkIdentifiers.add(value);
}
});
pointerAddresses.forEach((value) => {
if (value) {
existingMeta.chunkAddresses.add(value);
}
});
indexSnapshots.set(snapshotId, existingMeta);
}
}
}
let latestIndexSnapshotMeta = null;
for (const [snapshotId, meta] of indexSnapshots.entries()) {
if (!meta?.event) {
continue;
}
const createdAt =
typeof meta.event.created_at === "number" ? meta.event.created_at : 0;
if (
!latestIndexSnapshotMeta ||
createdAt > latestIndexSnapshotMeta.createdAt
) {
latestIndexSnapshotMeta = {
snapshotId,
createdAt,
};
}
}
if (
latestIndexSnapshotMeta?.snapshotId &&
cachedSnapshotId &&
cachedSnapshotId !== latestIndexSnapshotMeta.snapshotId
) {
snapshotChanged = true;
this.watchHistoryCache.delete(actor);
}
let newestSnapshotChunkTarget = 0;
let newestSnapshotIdentifierTarget = 0;
if (latestIndexSnapshotMeta?.snapshotId) {
const latestSnapshotMeta = indexSnapshots.get(
latestIndexSnapshotMeta.snapshotId
);
if (latestSnapshotMeta) {
const totalChunksForLatest = Number.isFinite(
latestSnapshotMeta.totalChunks
)
? Math.max(0, Math.floor(latestSnapshotMeta.totalChunks))
: 0;
if (totalChunksForLatest > 0) {
newestSnapshotChunkTarget = totalChunksForLatest + 1;
}
if (latestSnapshotMeta.chunkIdentifiers instanceof Set) {
const identifierCount = latestSnapshotMeta.chunkIdentifiers.size;
if (identifierCount > 0) {
newestSnapshotIdentifierTarget = identifierCount + 1;
}
}
}
}
const chunkFilters = [];
const chunkFetchLimit = Math.max(
fetchLimit,
chunkIdentifiers.size ? chunkIdentifiers.size + 1 : 0,
snapshotIds.size ? snapshotIds.size * 2 : 0,
newestSnapshotChunkTarget,
newestSnapshotIdentifierTarget
);
if (chunkIdentifiers.size) {
chunkFilters.push({
kinds: WATCH_HISTORY_READ_KINDS,
authors: [actor],
"#d": Array.from(chunkIdentifiers),
limit: chunkFetchLimit,
});
}
if (snapshotIds.size) {
chunkFilters.push({
kinds: WATCH_HISTORY_READ_KINDS,
authors: [actor],
"#snapshot": Array.from(snapshotIds),
limit: chunkFetchLimit,
});
}
let fetchedChunkEvents = [];
if (chunkFilters.length) {
try {
const events = await this.pool.list(relayList, chunkFilters);
if (Array.isArray(events)) {
fetchedChunkEvents = events;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch watch history chunks:",
error
);
}
}
}
const eventDedupe = new Map();
const registerEvent = (evt) => {
if (!evt || typeof evt !== "object") {
return;
}
const id = typeof evt.id === "string" ? evt.id : null;
if (!id) {
return;
}
const existing = eventDedupe.get(id);
if (
!existing ||
(typeof evt.created_at === "number" ? evt.created_at : 0) >
(typeof existing.created_at === "number" ? existing.created_at : 0)
) {
eventDedupe.set(id, evt);
}
};
fetchedHeadEvents.forEach(registerEvent);
fetchedChunkEvents.forEach(registerEvent);
const fetchedEvents = Array.from(eventDedupe.values());
fetchedEvents.sort((a, b) => {
const aCreated = typeof a?.created_at === "number" ? a.created_at : 0;
const bCreated = typeof b?.created_at === "number" ? b.created_at : 0;
return bCreated - aCreated;
});
const snapshotMap = new Map();
for (const event of fetchedEvents) {
if (!event || typeof event !== "object") {
continue;
}
const tags = Array.isArray(event.tags) ? event.tags : [];
let taggedSnapshotId = "";
let taggedChunkIndex = null;
let taggedTotalChunks = null;
let identifierTagValue = "";
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
identifierTagValue = tag[1];
}
if (tag[0] === "snapshot" && typeof tag[1] === "string") {
taggedSnapshotId = tag[1];
} else if (tag[0] === "chunk") {
const index = Number.parseInt(tag[1], 10);
const total = Number.parseInt(tag[2], 10);
if (Number.isFinite(index)) {
taggedChunkIndex = Math.max(0, index);
}
if (Number.isFinite(total) && total > 0) {
taggedTotalChunks = total;
}
}
}
const payload = await this.decryptWatchHistoryEvent(event, actor);
let snapshotId =
(typeof payload.snapshot === "string" && payload.snapshot) ||
taggedSnapshotId;
if (!snapshotId) {
const createdAt =
typeof event.created_at === "number" ? event.created_at : 0;
const fallbackId =
typeof event.id === "string" && event.id
? event.id
: `${createdAt}:${Math.random().toString(36).slice(2, 10)}`;
snapshotId = fallbackId;
}
let chunkIndex = Number.isFinite(payload.chunkIndex)
? Math.max(0, Math.floor(payload.chunkIndex))
: 0;
let totalChunks = Number.isFinite(payload.totalChunks)
? Math.max(1, Math.floor(payload.totalChunks))
: 1;
if (Number.isFinite(taggedChunkIndex)) {
chunkIndex = Math.max(0, Math.floor(taggedChunkIndex));
}
if (Number.isFinite(taggedTotalChunks)) {
totalChunks = Math.max(1, Math.floor(taggedTotalChunks));
}
const bucket = snapshotMap.get(snapshotId) || {
snapshotId,
chunks: new Map(),
expectedChunks: totalChunks,
latestCreatedAt:
typeof event.created_at === "number" ? event.created_at : 0,
version: payload.version || 0,
indexEvent: null,
chunkAddresses: new Set(),
expectedChunkIdentifiers: new Set(),
chunkIdentifiers: new Set(),
needsSignerRetry: new Set(),
decryptedChunkCount: 0,
hadFallbackChunk: false,
};
if (!(bucket.needsSignerRetry instanceof Set)) {
bucket.needsSignerRetry = new Set();
}
if (!(bucket.chunkIdentifiers instanceof Set)) {
bucket.chunkIdentifiers = new Set();
}
if (!Number.isFinite(bucket.decryptedChunkCount)) {
bucket.decryptedChunkCount = 0;
}
const chunkPayload = {
...payload,
snapshot: snapshotId,
chunkIndex,
totalChunks,
needsSignerRetry: !!payload?.needsSignerRetry,
chunkIdentifier: identifierTagValue || payload?.chunkIdentifier || null,
};
bucket.chunks.set(chunkIndex, {
event,
payload: chunkPayload,
created_at: typeof event.created_at === "number" ? event.created_at : 0,
});
if (identifierTagValue) {
bucket.chunkIdentifiers.add(identifierTagValue);
}
const payloadVersion = Number.isFinite(chunkPayload.version)
? chunkPayload.version
: 0;
const payloadItems = Array.isArray(chunkPayload.items)
? chunkPayload.items
: [];
const payloadHasWatchedAt = payloadItems.every((item) =>
item && Number.isFinite(item.watchedAt)
);
const payloadMatchesSnapshot =
typeof chunkPayload.snapshot === "string"
? chunkPayload.snapshot === snapshotId
: true;
const chunkComplete =
payloadVersion >= 2 &&
payloadMatchesSnapshot &&
!chunkPayload.needsSignerRetry &&
payloadHasWatchedAt;
if (chunkComplete) {
bucket.decryptedChunkCount += 1;
} else {
bucket.hadFallbackChunk = true;
}
bucket.expectedChunks = Math.max(bucket.expectedChunks, totalChunks);
const createdAt =
typeof event.created_at === "number" ? event.created_at : 0;
if (createdAt > bucket.latestCreatedAt) {
bucket.latestCreatedAt = createdAt;
}
bucket.version = Math.max(bucket.version, payload.version || 0);
if (payload?.needsSignerRetry) {
bucket.needsSignerRetry.add(chunkIndex);
} else {
bucket.needsSignerRetry.delete(chunkIndex);
}
const chunkAddress = eventToAddressPointer(event);
if (chunkAddress) {
bucket.chunkAddresses.add(chunkAddress);
}
if (identifierTagValue) {
bucket.expectedChunkIdentifiers.add(identifierTagValue);
}
snapshotMap.set(snapshotId, bucket);
}
for (const [snapshotId, meta] of indexSnapshots.entries()) {
const bucket = snapshotMap.get(snapshotId) || {
snapshotId,
chunks: new Map(),
expectedChunks: 0,
latestCreatedAt: 0,
version: 0,
indexEvent: null,
chunkAddresses: new Set(),
expectedChunkIdentifiers: new Set(),
chunkIdentifiers: new Set(),
needsSignerRetry: new Set(),
decryptedChunkCount: 0,
hadFallbackChunk: false,
};
if (!(bucket.needsSignerRetry instanceof Set)) {
bucket.needsSignerRetry = new Set();
}
if (!(bucket.chunkIdentifiers instanceof Set)) {
bucket.chunkIdentifiers = new Set();
}
if (!Number.isFinite(bucket.decryptedChunkCount)) {
bucket.decryptedChunkCount = 0;
}
if (meta.event) {
const createdAt =
typeof meta.event.created_at === "number" ? meta.event.created_at : 0;
if (createdAt > bucket.latestCreatedAt) {
bucket.latestCreatedAt = createdAt;
}
bucket.indexEvent = meta.event;
}
if (Number.isFinite(meta.totalChunks)) {
const normalized = Math.max(1, Math.floor(meta.totalChunks));
bucket.expectedChunks = Math.max(bucket.expectedChunks, normalized);
}
if (meta.chunkAddresses instanceof Set) {
meta.chunkAddresses.forEach((value) => {
if (typeof value === "string" && value) {
bucket.chunkAddresses.add(value);
}
});
}
if (meta.chunkIdentifiers instanceof Set) {
meta.chunkIdentifiers.forEach((value) => {
if (typeof value === "string" && value) {
bucket.expectedChunkIdentifiers.add(value);
}
});
}
snapshotMap.set(snapshotId, bucket);
}
const snapshotBuckets = Array.from(snapshotMap.values()).sort(
(a, b) => b.latestCreatedAt - a.latestCreatedAt
);
let selectedSnapshot = null;
let fallbackSnapshot = null;
for (const bucket of snapshotBuckets) {
const chunkIndices = Array.from(bucket.chunks.keys()).sort(
(a, b) => a - b
);
const seenKeys = new Set();
const combinedItems = [];
let pointerCandidate = bucket.indexEvent || null;
for (const index of chunkIndices) {
const chunk = bucket.chunks.get(index);
if (!chunk) {
continue;
}
if (!pointerCandidate && chunk.payload.chunkIndex === 0) {
pointerCandidate = chunk.event;
}
const items = Array.isArray(chunk.payload.items)
? chunk.payload.items
: [];
for (const pointer of items) {
const key = pointerKey(pointer);
if (!key || seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
combinedItems.push(pointer);
}
}
if (!pointerCandidate && chunkIndices.length) {
const firstChunk = bucket.chunks.get(chunkIndices[0]);
pointerCandidate = firstChunk?.event || null;
}
const snapshot = {
snapshotId: bucket.snapshotId,
items: combinedItems,
pointerEvent: pointerCandidate || null,
version: bucket.version,
chunkCount: chunkIndices.length,
expectedChunks: Math.max(bucket.expectedChunks, chunkIndices.length || 1),
chunkAddresses: bucket.chunkAddresses instanceof Set
? new Set(bucket.chunkAddresses)
: new Set(),
expectedChunkIdentifiers: bucket.expectedChunkIdentifiers instanceof Set
? new Set(bucket.expectedChunkIdentifiers)
: new Set(),
chunkIdentifiers:
bucket.chunkIdentifiers instanceof Set
? new Set(bucket.chunkIdentifiers)
: new Set(),
needsSignerRetry: bucket.needsSignerRetry instanceof Set
? new Set(bucket.needsSignerRetry)
: new Set(),
decryptedChunkCount: Number.isFinite(bucket.decryptedChunkCount)
? Math.max(0, Math.floor(bucket.decryptedChunkCount))
: 0,
hadFallbackChunk: bucket.hadFallbackChunk === true,
};
if (!fallbackSnapshot) {
fallbackSnapshot = snapshot;
}
if (snapshot.chunkCount >= snapshot.expectedChunks) {
selectedSnapshot = snapshot;
break;
}
if (!selectedSnapshot) {
selectedSnapshot = snapshot;
}
}
const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null;
const previousEntry = snapshotChanged ? null : priorEntry;
const storedItems =
!snapshotChanged && Array.isArray(stored?.items) ? stored.items : [];
const storedPointerEvent =
!snapshotChanged && stored?.pointerEvent ? stored.pointerEvent : null;
const previousItems = Array.isArray(previousEntry?.items)
? previousEntry.items
: [];
const fallbackCandidate = previousItems.length
? {
source: "memory-cache",
items: previousItems,
pointer: previousEntry?.pointerEvent || null,
}
: storedItems.length
? {
source: "storage-cache",
items: storedItems,
pointer: storedPointerEvent || null,
}
: null;
const remoteItems = Array.isArray(effectiveSnapshot?.items)
? effectiveSnapshot.items
: [];
const expectedChunks = Number.isFinite(effectiveSnapshot?.expectedChunks)
? Math.max(1, Math.floor(effectiveSnapshot.expectedChunks))
: 1;
const chunkCount = Number.isFinite(effectiveSnapshot?.chunkCount)
? Math.max(0, Math.floor(effectiveSnapshot.chunkCount))
: 0;
const chunkIdentifiersSet =
effectiveSnapshot?.chunkIdentifiers instanceof Set
? effectiveSnapshot.chunkIdentifiers
: new Set();
const expectedChunkIdentifiersSet =
effectiveSnapshot?.expectedChunkIdentifiers instanceof Set
? effectiveSnapshot.expectedChunkIdentifiers
: new Set();
const missingIdentifierList = Array.from(expectedChunkIdentifiersSet).filter(
(identifier) => !chunkIdentifiersSet.has(identifier)
);
const needsSignerRetryCount =
effectiveSnapshot?.needsSignerRetry instanceof Set
? effectiveSnapshot.needsSignerRetry.size
: 0;
const decryptedChunkCount = Number.isFinite(
effectiveSnapshot?.decryptedChunkCount
)
? Math.max(0, Math.floor(effectiveSnapshot.decryptedChunkCount))
: 0;
const expectedDecryptedTarget = Math.min(
expectedChunks,
chunkCount || expectedChunks
);
const remoteVersion = effectiveSnapshot?.version ?? 0;
const remoteComplete =
!!effectiveSnapshot &&
chunkCount >= expectedChunks &&
missingIdentifierList.length === 0 &&
needsSignerRetryCount === 0 &&
effectiveSnapshot.hadFallbackChunk !== true &&
decryptedChunkCount >= expectedDecryptedTarget;
let items = remoteItems;
let pointerForEntry = effectiveSnapshot?.pointerEvent || null;
let usedFallback = false;
let remoteUsed = false;
let remoteIssues = null;
let fallbackSource = null;
if (effectiveSnapshot) {
if (remoteComplete) {
remoteUsed = true;
} else {
remoteIssues = {
snapshotId: effectiveSnapshot.snapshotId || "",
chunkCount,
expectedChunks,
decryptedChunks: decryptedChunkCount,
missingChunkIdentifiers: missingIdentifierList.length,
needsSignerRetry: needsSignerRetryCount,
hadFallbackChunk: effectiveSnapshot.hadFallbackChunk === true,
version: remoteVersion,
};
if (fallbackCandidate && fallbackCandidate.items.length) {
items = fallbackCandidate.items;
pointerForEntry = fallbackCandidate.pointer || null;
usedFallback = true;
fallbackSource = fallbackCandidate.source;
} else if (!remoteItems.length && remoteVersion === 0) {
remoteUsed = true;
} else {
remoteUsed = true;
}
}
} else if (fallbackCandidate && fallbackCandidate.items.length) {
items = fallbackCandidate.items;
pointerForEntry = fallbackCandidate.pointer || null;
usedFallback = true;
fallbackSource = fallbackCandidate.source;
}
if (!pointerForEntry && fallbackCandidate) {
pointerForEntry = fallbackCandidate.pointer || null;
}
if (!remoteUsed && effectiveSnapshot && !usedFallback && !items.length) {
remoteUsed = true;
}
const actorPreview =
typeof actor === "string" && actor.length > 16
? `${actor.slice(0, 8)}…${actor.slice(-4)}`
: actor;
const logContext = {
actor: actorPreview || null,
snapshot: effectiveSnapshot?.snapshotId || null,
itemCount: Array.isArray(items) ? items.length : 0,
chunkCount,
expectedChunks,
decryptedChunks: decryptedChunkCount,
missingChunkIdentifiers: missingIdentifierList.length,
needsSignerRetry: needsSignerRetryCount,
hadFallbackChunk: effectiveSnapshot?.hadFallbackChunk === true,
fallbackSource: fallbackSource || null,
};
if (effectiveSnapshot && remoteUsed && remoteComplete) {
console.info("[nostr] Watch history snapshot rehydrated:", {
...logContext,
remoteComplete: true,
});
} else if (effectiveSnapshot && !remoteComplete && usedFallback) {
console.warn("[nostr] Watch history snapshot incomplete; using cached copy:", {
...logContext,
remoteComplete: false,
issues: remoteIssues,
});
} else if (effectiveSnapshot && !remoteComplete && remoteUsed) {
console.warn(
"[nostr] Watch history snapshot incomplete with no fallback:",
{
...logContext,
remoteComplete: false,
issues: remoteIssues,
}
);
} else if (!effectiveSnapshot && usedFallback) {
console.info("[nostr] Watch history snapshot unavailable; using cached copy:", {
...logContext,
remoteComplete: false,
});
} else if (!effectiveSnapshot && !items.length) {
console.info("[nostr] Watch history snapshot unavailable:", {
actor: actorPreview || null,
});
}
const entry = this.createWatchHistoryEntry(
pointerForEntry,
items,
Date.now(),
previousEntry
);
if (!(entry.chunkEvents instanceof Map)) {
entry.chunkEvents = new Map();
} else if (remoteUsed) {
entry.chunkEvents.clear();
}
if (!(entry.chunkNeedsSigner instanceof Set)) {
entry.chunkNeedsSigner = new Set();
} else if (remoteUsed) {
entry.chunkNeedsSigner.clear();
}
if (remoteUsed && effectiveSnapshot) {
const chunkPointerItemsForEntry = [];
const chunkPointerKeys = new Set();
let indexPointerForEntry = null;
let firstChunkPointer = null;
const pointerEventForSnapshot = effectiveSnapshot.pointerEvent;
if (pointerEventForSnapshot) {
const indexAddress = eventToAddressPointer(pointerEventForSnapshot);
if (indexAddress) {
const pointer = clonePointerItem({
type: "a",
value: indexAddress,
relay: null,
});
if (pointer) {
const key = pointerKey(pointer);
if (key) {
chunkPointerKeys.add(key);
const clonedIndex = cloneEventForCache(pointerEventForSnapshot);
if (clonedIndex) {
entry.chunkEvents.set(key, clonedIndex);
}
indexPointerForEntry = pointer;
}
}
}
}
if (effectiveSnapshot.snapshotId) {
const bucketForEntry = snapshotMap.get(effectiveSnapshot.snapshotId);
if (bucketForEntry) {
const chunkEntries = Array.from(bucketForEntry.chunks.values()).sort(
(a, b) => a.payload.chunkIndex - b.payload.chunkIndex
);
for (const chunk of chunkEntries) {
if (!chunk?.event) {
continue;
}
const addressValue = eventToAddressPointer(chunk.event);
if (!addressValue) {
continue;
}
const pointer = clonePointerItem({
type: "a",
value: addressValue,
relay: null,
});
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key || chunkPointerKeys.has(key)) {
continue;
}
const clonedChunkEvent = cloneEventForCache(chunk.event);
const chunkPayloadIndex = Number.isFinite(chunk.payload?.chunkIndex)
? Math.max(0, Math.floor(chunk.payload.chunkIndex))
: null;
const flaggedBySnapshot =
chunkPayloadIndex !== null &&
effectiveSnapshot.needsSignerRetry instanceof Set &&
effectiveSnapshot.needsSignerRetry.has(chunkPayloadIndex);
const needsSignerRetry =
!!chunk.payload?.needsSignerRetry || flaggedBySnapshot;
if (needsSignerRetry) {
entry.chunkNeedsSigner.add(key);
entry.resolved.delete(key);
} else if (clonedChunkEvent) {
entry.chunkEvents.set(key, clonedChunkEvent);
} else {
entry.chunkEvents.set(key, chunk.event);
}
chunkPointerKeys.add(key);
chunkPointerItemsForEntry.push(pointer);
if (!firstChunkPointer && chunkPayloadIndex === 0) {
firstChunkPointer = pointer;
}
}
}
}
if (effectiveSnapshot.chunkAddresses instanceof Set) {
effectiveSnapshot.chunkAddresses.forEach((address) => {
const pointer = clonePointerItem({
type: "a",
value: address,
relay: null,
});
if (!pointer) {
return;
}
const key = pointerKey(pointer);
if (!key || chunkPointerKeys.has(key)) {
return;
}
chunkPointerKeys.add(key);
chunkPointerItemsForEntry.push(pointer);
});
}
const headPointer = indexPointerForEntry || firstChunkPointer || null;
entry.chunkPointers = {
head: headPointer,
index: indexPointerForEntry,
chunks: chunkPointerItemsForEntry,
};
if (headPointer) {
const headPointerKey = pointerKey(headPointer);
if (headPointerKey) {
chunkPointerKeys.add(headPointerKey);
const indexPointerKey = indexPointerForEntry
? pointerKey(indexPointerForEntry)
: "";
if (
entry.pointerEvent &&
indexPointerKey &&
headPointerKey === indexPointerKey
) {
entry.chunkEvents.set(
headPointerKey,
cloneEventForCache(entry.pointerEvent)
);
}
}
}
for (const key of Array.from(entry.resolved.keys())) {
if (!key || !key.startsWith("a:")) {
continue;
}
const pointerValue = key.slice(2);
if (!pointerValue) {
continue;
}
const pointerKind = Number.parseInt(pointerValue.split(":")[0], 10);
if (!Number.isFinite(pointerKind) || !isWatchHistoryKind(pointerKind)) {
continue;
}
if (!chunkPointerKeys.has(key)) {
entry.resolved.delete(key);
}
}
for (const key of chunkPointerKeys) {
const eventForKey = entry.chunkEvents.get(key);
if (eventForKey) {
entry.resolved.set(key, cloneEventForCache(eventForKey));
}
}
}
this.watchHistoryCache.set(actor, entry);
this.persistWatchHistoryEntry(actor, entry);
if (
(!effectiveSnapshot || !effectiveSnapshot.pointerEvent) &&
usedFallback &&
entry.items.length
) {
this.scheduleWatchHistoryRepublish(actor, entry.items);
}
return {
pointerEvent: entry.pointerEvent
? cloneEventForCache(entry.pointerEvent)
: null,
items: entry.items
.map((item) => clonePointerItem(item))
.filter(Boolean),
};
}
async resolveWatchHistory(batchSize = 20) {
if (!this.pool) {
return [];
}
const actor = await this.ensureSessionActor();
if (!actor) {
return [];
}
await this.fetchWatchHistory(actor);
const entry = this.watchHistoryCache.get(actor);
if (!entry) {
return [];
}
if (!(entry.resolvedVideos instanceof Map)) {
entry.resolvedVideos = new Map();
}
if (!(entry.delivered instanceof Set)) {
entry.delivered = new Set();
}
const parsedBatchSize = Number(batchSize);
const normalizedBatchSize =
Number.isFinite(parsedBatchSize) && parsedBatchSize > 0
? Math.floor(parsedBatchSize)
: 20;
const effectiveBatchSize = WATCH_HISTORY_BATCH_RESOLVE
? Math.max(1, normalizedBatchSize)
: 1;
const classifyWatchHistoryPointer = (pointer, ownerPubkey) => {
if (!pointer || pointer.type !== "a") {
return "none";
}
const value =
typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return "none";
}
const parts = value.split(":");
if (parts.length < 3) {
return "none";
}
const [kindStr, pubkeyRaw, ...identifierParts] = parts;
const kind = Number.parseInt(kindStr, 10);
if (!Number.isFinite(kind) || !isWatchHistoryKind(kind)) {
return "none";
}
const identifier = identifierParts.join(":");
const normalizedIdentifier =
typeof identifier === "string" ? identifier.trim() : "";
if (!normalizedIdentifier) {
return "none";
}
if (isIndexIdentifier(normalizedIdentifier)) {
return "head";
}
if (isLegacyHeadIdentifier(normalizedIdentifier)) {
return "head";
}
if (!isWatchHistoryChunkIdentifier(normalizedIdentifier)) {
return "none";
}
const normalizedOwner =
typeof ownerPubkey === "string" && ownerPubkey.trim()
? ownerPubkey.trim().toLowerCase()
: "";
const normalizedPubkey =
typeof pubkeyRaw === "string" && pubkeyRaw.trim()
? pubkeyRaw.trim().toLowerCase()
: "";
if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) {
return "chunk";
}
return "none";
};
const addPointerToMap = (pointer, map) => {
const normalized = clonePointerItem(pointer);
if (!normalized) {
return null;
}
const key = pointerKey(normalized);
if (!key) {
return null;
}
map.set(key, normalized);
return normalized;
};
const headPointerMap = new Map();
const chunkPointerMap = new Map();
const videoCandidates = [];
if (entry.chunkPointers?.head) {
addPointerToMap(entry.chunkPointers.head, headPointerMap);
}
if (Array.isArray(entry.chunkPointers?.chunks)) {
for (const chunkPointer of entry.chunkPointers.chunks) {
addPointerToMap(chunkPointer, chunkPointerMap);
}
}
for (const item of entry.items) {
const pointer = clonePointerItem(item);
if (!pointer) {
continue;
}
const pointerType = classifyWatchHistoryPointer(pointer, actor);
if (pointerType === "head") {
addPointerToMap(pointer, headPointerMap);
continue;
}
if (pointerType === "chunk") {
addPointerToMap(pointer, chunkPointerMap);
continue;
}
videoCandidates.push(pointer);
}
const pointerEventClone = entry.pointerEvent
? cloneEventForCache(entry.pointerEvent)
: null;
for (const pointer of headPointerMap.values()) {
const key = pointerKey(pointer);
if (!key) {
continue;
}
if (pointerEventClone) {
entry.resolved.set(key, pointerEventClone);
if (entry.chunkEvents instanceof Map) {
entry.chunkEvents.set(key, pointerEventClone);
}
} else if (!entry.resolved.has(key)) {
entry.resolved.set(key, null);
}
entry.resolving.delete(key);
}
const available = videoCandidates.filter((item) => {
const key = pointerKey(item);
if (!key) {
return false;
}
if (entry.delivered.has(key)) {
return false;
}
if (entry.resolving.has(key)) {
return false;
}
if (entry.resolvedVideos.has(key)) {
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
return false;
}
}
return true;
});
const chunkAvailable = [];
for (const [key, pointer] of chunkPointerMap.entries()) {
if (!key) {
continue;
}
if (entry.resolved.has(key)) {
continue;
}
if (entry.resolving.has(key)) {
continue;
}
const cachedChunk = entry.chunkEvents?.get?.(key);
if (cachedChunk) {
const cloned = cloneEventForCache(cachedChunk);
if (cloned) {
entry.chunkEvents.set(key, cloned);
entry.resolved.set(key, cloned);
} else {
entry.resolved.set(key, cachedChunk);
}
continue;
}
chunkAvailable.push(pointer);
}
const videoBatch = available.slice(0, effectiveBatchSize);
const batch = [...videoBatch, ...chunkAvailable];
const relaySet = new Set(
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS
);
const filters = [];
if (batch.length) {
batch.forEach((item) => {
const key = pointerKey(item);
if (key) {
entry.resolving.add(key);
}
});
for (const pointer of batch) {
if (pointer.relay) {
relaySet.add(pointer.relay);
}
if (pointer.type === "e") {
filters.push({ ids: [pointer.value] });
} else if (pointer.type === "a") {
const [kindStr, pubkey, identifier] = pointer.value.split(":");
const kind = Number(kindStr);
if (Number.isFinite(kind) && pubkey) {
const filter = { kinds: [kind], authors: [pubkey] };
if (identifier) {
filter["#d"] = [identifier];
}
filters.push(filter);
}
}
}
}
let events = [];
if (filters.length) {
try {
events = await this.pool.list(Array.from(relaySet), filters);
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to resolve watch history batch:", error);
}
}
} else if (batch.length) {
batch.forEach((item) => entry.resolving.delete(pointerKey(item)));
}
const pointerMatches = new Map();
for (const evt of events || []) {
if (!evt || typeof evt !== "object") {
continue;
}
if (typeof evt.id === "string" && evt.id) {
pointerMatches.set(
pointerKey({ type: "e", value: evt.id }),
evt
);
}
const addressPointer = eventToAddressPointer(evt);
if (addressPointer) {
pointerMatches.set(
pointerKey({ type: "a", value: addressPointer }),
evt
);
}
}
for (const pointer of batch) {
const key = pointerKey(pointer);
if (!key) {
continue;
}
const pointerType = classifyWatchHistoryPointer(pointer, actor);
const event = pointerMatches.get(key);
if (pointerType === "chunk" || pointerType === "head") {
if (event) {
const cloned = cloneEventForCache(event) || event;
if (!entry.chunkEvents) {
entry.chunkEvents = new Map();
}
entry.chunkEvents.set(key, cloned);
entry.resolved.set(key, cloned);
}
entry.resolving.delete(key);
continue;
}
if (event) {
try {
const video = convertEventToVideo(event);
if (!video.invalid) {
this.allEvents.set(event.id, video);
if (!video.deleted) {
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
entry.resolvedVideos.set(key, video);
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to convert watch history event:", err);
}
}
}
entry.resolving.delete(key);
}
const deliverableVideos = [];
for (const pointer of videoCandidates) {
if (deliverableVideos.length >= effectiveBatchSize) {
break;
}
const key = pointerKey(pointer);
if (!key || entry.delivered.has(key)) {
continue;
}
const cachedVideo = entry.resolvedVideos.get(key);
if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) {
const pointerClone = clonePointerItem(pointer);
const watchedAt = Number.isFinite(pointerClone?.watchedAt)
? pointerClone.watchedAt
: 0;
const videoWithHistory = {
...cachedVideo,
watchHistory: {
key,
watchedAt,
pointer:
pointerClone || {
type: pointer.type,
value: pointer.value,
relay: pointer.relay || null,
watchedAt,
},
},
};
deliverableVideos.push(videoWithHistory);
entry.delivered.add(key);
}
}
return deliverableVideos;
}
async recordVideoView(videoPointer, options = {}) {
const pointer = normalizePointerInput(videoPointer);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const view = await this.publishViewEvent(pointer, options);
if (view?.ok && view.event) {
try {
const ingest = await loadIngestLocalViewEvent();
if (typeof ingest === "function") {
ingest({ event: view.event, pointer });
}
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to ingest optimistic view event:",
error
);
}
}
}
return view;
}
/**
* Connect to the configured relays
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
this.restoreLocalData();
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) {
throw new Error("No relays connected");
}
if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
// We subscribe to kind `0` purely as a liveness probe because almost every
// relay can answer it quickly. Either an `event` or `eose` signals success,
// while the 5s timer guards against relays that never respond. We immediately
// `unsub` to avoid leaking subscriptions. Note: any future change must still
// provide a lightweight readiness check with similar timeout semantics.
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Attempt login with a Nostr extension
*/
async login(options = {}) {
try {
const extension = window.nostr;
if (!extension) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
);
}
if (typeof extension.enable === "function") {
if (isDevMode) {
console.log("Requesting permissions from NIP-07 extension...");
}
try {
await withNip07Timeout(() => extension.enable());
} catch (enableErr) {
throw new Error(
enableErr && enableErr.message
? enableErr.message
: "The NIP-07 extension denied the permission request."
);
}
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await withNip07Timeout(() =>
extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined)
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
const pubkey = await withNip07Timeout(() => extension.getPublicKey());
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again."
);
}
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
}
// Access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to whitelisted users only.");
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
}
return this.pubkey;
} catch (err) {
console.error("Login error:", err);
throw err;
}
}
logout() {
this.pubkey = null;
for (const timer of this.watchHistoryRepublishTimers.values()) {
clearTimeout(timer);
}
this.watchHistoryRepublishTimers.clear();
if (isDevMode) console.log("User logged out.");
}
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) {
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
const trimmedMessage = typeof message === "string" ? message.trim() : "";
if (!trimmedTarget) {
return { ok: false, error: "invalid-target" };
}
if (!trimmedMessage) {
return { ok: false, error: "empty-message" };
}
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const extension = window?.nostr;
if (!extension) {
return { ok: false, error: "nostr-extension-missing" };
}
const nip04 = extension.nip04;
if (!nip04 || typeof nip04.encrypt !== "function") {
return { ok: false, error: "nip04-unavailable" };
}
if (typeof extension.signEvent !== "function") {
return { ok: false, error: "sign-event-unavailable" };
}
let actorHex =
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
? actorPubkeyOverride.trim()
: "";
if (!actorHex && typeof this.pubkey === "string") {
actorHex = this.pubkey.trim();
}
if (!actorHex && typeof extension.getPublicKey === "function") {
try {
actorHex = await extension.getPublicKey();
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch actor pubkey from extension:",
error
);
}
}
}
if (!actorHex) {
return { ok: false, error: "missing-actor-pubkey" };
}
const targetHex = decodeNpubToHex(trimmedTarget);
if (!targetHex) {
return { ok: false, error: "invalid-target" };
}
let ciphertext = "";
try {
ciphertext = await nip04.encrypt(targetHex, trimmedMessage);
} catch (error) {
return { ok: false, error: "encryption-failed", details: error };
}
const event = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", targetHex]],
content: ciphertext,
pubkey: actorHex,
};
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
return { ok: false, error: "signature-failed", details: error };
}
const relays =
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
const publishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent))
);
const success = publishResults.some((result) => result.success);
if (!success) {
return {
ok: false,
error: "publish-failed",
details: publishResults.filter((result) => !result.success),
};
}
return { ok: true };
}
/**
* Publish a new video using the v3 content schema.
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
}
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
let finalMagnet = rawMagnet.trim();
if (videoData.isPrivate && finalMagnet) {
finalMagnet = fakeEncrypt(finalMagnet);
}
const finalUrl =
typeof videoData.url === "string" ? videoData.url.trim() : "";
const finalThumbnail =
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
const finalDescription =
typeof videoData.description === "string"
? videoData.description.trim()
: "";
const finalTitle =
typeof videoData.title === "string" ? videoData.title.trim() : "";
const providedMimeType =
typeof videoData.mimeType === "string"
? videoData.mimeType.trim()
: "";
const createdAt = Math.floor(Date.now() / 1000);
// brand-new root & d
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const finalEnableComments =
videoData.enableComments === false ? false : true;
const finalWs =
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
const finalXs =
typeof videoData.xs === "string" ? videoData.xs.trim() : "";
const contentObject = {
version: 3,
title: finalTitle,
url: finalUrl,
magnet: finalMagnet,
thumbnail: finalThumbnail,
description: finalDescription,
mode: videoData.mode || "live",
videoRootId,
deleted: false,
isPrivate: videoData.isPrivate ?? false,
enableComments: finalEnableComments,
};
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
const event = buildVideoPostEvent({
pubkey,
created_at: createdAt,
dTagValue,
content: contentObject,
});
if (isDevMode) {
console.log("Publish event with brand-new root:", videoRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) console.log("Signed event:", signedEvent);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "video note",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Video note rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
const { accepted: acceptedRelays, failed: failedRelays } = publishSummary;
if (isDevMode) {
acceptedRelays.forEach(({ url }) =>
console.log(`Video published to ${url}`)
);
}
if (failedRelays.length) {
failedRelays.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] Video note not accepted by ${url}: ${reason}`,
relayError
);
});
}
if (finalUrl) {
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
const mimeType =
providedMimeType || inferredMimeType || "application/octet-stream";
const mirrorTags = [
["url", finalUrl],
["m", mimeType],
];
if (finalThumbnail) {
mirrorTags.push(["thumb", finalThumbnail]);
}
const altText = finalDescription || finalTitle || "";
if (altText) {
mirrorTags.push(["alt", altText]);
}
if (!contentObject.isPrivate && finalMagnet) {
mirrorTags.push(["magnet", finalMagnet]);
}
const mirrorEvent = buildVideoMirrorEvent({
pubkey,
created_at: createdAt,
tags: mirrorTags,
content: altText,
});
if (isDevMode) {
console.log("Prepared NIP-94 mirror event:", mirrorEvent);
}
try {
const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent);
if (isDevMode) {
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
}
const mirrorResults = await publishEventToRelays(
this.pool,
this.relays,
signedMirrorEvent
);
try {
const mirrorSummary = assertAnyRelayAccepted(mirrorResults, {
context: "NIP-94 mirror",
});
if (isDevMode) {
mirrorSummary.accepted.forEach(({ url }) =>
console.log(`NIP-94 mirror published to ${url}`)
);
}
if (mirrorSummary.failed.length) {
mirrorSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] NIP-94 mirror not accepted by ${url}: ${reason}`,
relayError
);
});
}
} catch (mirrorError) {
if (mirrorError?.relayFailures?.length) {
mirrorError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.warn(
`[nostr] NIP-94 mirror rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
} else if (isDevMode) {
console.warn(
"[nostr] NIP-94 mirror rejected by all relays:",
mirrorError
);
}
}
if (isDevMode) {
console.log(
"NIP-94 mirror dispatched for hosted URL:",
finalUrl
);
}
} catch (mirrorError) {
if (isDevMode) {
console.error(
"Failed to sign/publish NIP-94 mirror event:",
mirrorError
);
}
}
} else if (isDevMode) {
console.log("Skipping NIP-94 mirror: no hosted URL provided.");
}
return signedEvent;
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
*
* This version forces version=2 for the original note and uses
* lowercase comparison for public keys.
*/
async editVideo(originalEventStub, updatedData, userPubkey) {
if (!userPubkey) {
throw new Error("Not logged in to edit.");
}
// Convert the provided pubkey to lowercase
const userPubkeyLower = userPubkey.toLowerCase();
// Use getEventById to fetch the full original event details
const baseEvent = await this.getEventById(originalEventStub.id);
if (!baseEvent) {
throw new Error("Could not retrieve the original event to edit.");
}
// Check that the original event is version 2 or higher
if (baseEvent.version < 2) {
throw new Error(
"This video is not in the supported version for editing."
);
}
// Ownership check (compare lowercase hex public keys)
if (
!baseEvent.pubkey ||
baseEvent.pubkey.toLowerCase() !== userPubkeyLower
) {
throw new Error("You do not own this video (pubkey mismatch).");
}
// Decrypt the old magnet if the note is private
let oldPlainMagnet = baseEvent.magnet || "";
if (baseEvent.isPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
const oldUrl = baseEvent.url || "";
// Determine if the updated note should be private
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
const magnetEdited = updatedData.magnetEdited === true;
const newMagnetValue =
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
let finalPlainMagnet = magnetEdited ? newMagnetValue : oldPlainMagnet;
let finalMagnet =
wantPrivate && finalPlainMagnet
? fakeEncrypt(finalPlainMagnet)
: finalPlainMagnet;
const urlEdited = updatedData.urlEdited === true;
const newUrlValue =
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
const finalUrl = urlEdited ? newUrlValue : oldUrl;
const wsEdited = updatedData.wsEdited === true;
const xsEdited = updatedData.xsEdited === true;
const newWsValue =
typeof updatedData.ws === "string" ? updatedData.ws.trim() : "";
const newXsValue =
typeof updatedData.xs === "string" ? updatedData.xs.trim() : "";
const baseWs =
typeof baseEvent.ws === "string" ? baseEvent.ws.trim() : "";
const baseXs =
typeof baseEvent.xs === "string" ? baseEvent.xs.trim() : "";
const finalWs = wsEdited ? newWsValue : baseWs;
const finalXs = xsEdited ? newXsValue : baseXs;
const finalEnableComments =
typeof updatedData.enableComments === "boolean"
? updatedData.enableComments
: baseEvent.enableComments === false
? false
: true;
// Use the existing videoRootId (or fall back to the base event's ID)
const oldRootId = baseEvent.videoRootId || baseEvent.id;
// Generate a new d-tag so that the edit gets its own share link
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// Build the updated content object
const contentObject = {
videoRootId: oldRootId,
version: updatedData.version ?? baseEvent.version ?? 2,
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? baseEvent.title,
url: finalUrl,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
mode: updatedData.mode ?? baseEvent.mode ?? "live",
enableComments: finalEnableComments,
};
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
const event = {
kind: 30078,
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
pubkey: userPubkeyLower,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", newD], // new share link tag
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Creating edited event with root ID:", oldRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "edited video note",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Edited video rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
if (isDevMode) {
publishSummary.accepted.forEach(({ url }) =>
console.log(`Edited video published to ${url}`)
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] Edited video not accepted by ${url}: ${reason}`,
relayError
);
});
}
return signedEvent;
} catch (err) {
console.error("Edit failed:", err);
throw err;
}
}
/**
* revertVideo => old style
*/
async revertVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to revert.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("Not your event (pubkey mismatch).");
}
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for reverting.");
}
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
url: fetched.url,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
const safeTags = Array.isArray(baseEvent.tags) ? baseEvent.tags : [];
const dTag = safeTags.find((t) => t[0] === "d");
const existingD = dTag ? dTag[1] : null;
let oldContent = {};
try {
oldContent = JSON.parse(baseEvent.content || "{}");
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to parse baseEvent.content while reverting:", err);
}
oldContent = {};
}
const oldVersion = oldContent.version ?? 1;
const finalRootId =
oldContent.videoRootId ||
(existingD
? `LEGACY:${baseEvent.pubkey}:${existingD}`
: baseEvent.id);
const contentObject = {
videoRootId: finalRootId,
version: oldVersion,
deleted: true,
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
url: "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
mode: oldContent.mode || "live",
};
const tags = [["t", "video"]];
if (existingD) {
tags.push(["d", existingD]);
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(contentObject),
};
const signedEvent = await window.nostr.signEvent(event);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "video revert",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Video revert rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
if (isDevMode) {
publishSummary.accepted.forEach(({ url }) =>
console.log(`Revert event published to ${url}`)
);
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] Video revert not accepted by ${url}: ${reason}`,
relayError
);
});
}
return signedEvent;
}
/**
* "Deleting" => Mark all content with the same videoRootId as {deleted:true}
* and blank out magnet/desc.
*
* This version now asks for confirmation before proceeding.
*/
async deleteAllVersions(videoRootId, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to delete all versions.");
}
// Ask for confirmation before proceeding
if (
!window.confirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone."
)
) {
console.log("Deletion cancelled by user.");
return null; // Cancel deletion if user clicks "Cancel"
}
// 1) Find all events in our local allEvents that share the same root.
const matchingEvents = [];
for (const [id, vid] of this.allEvents.entries()) {
if (
vid.videoRootId === videoRootId &&
vid.pubkey === pubkey &&
!vid.deleted
) {
matchingEvents.push(vid);
}
}
if (!matchingEvents.length) {
throw new Error("No existing events found for that root.");
}
// 2) For each event, create a "revert" event to mark it as deleted.
// This will prompt the user (via the extension) to sign the deletion.
for (const vid of matchingEvents) {
await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
content: JSON.stringify({
version: vid.version,
deleted: vid.deleted,
isPrivate: vid.isPrivate,
title: vid.title,
url: vid.url,
magnet: vid.magnet,
thumbnail: vid.thumbnail,
description: vid.description,
mode: vid.mode,
}),
tags: vid.tags,
},
pubkey
);
}
return true;
}
/**
* Saves all known events to localStorage (or a different storage if you prefer).
*/
saveLocalData() {
if (typeof localStorage === "undefined") {
return;
}
const payload = {
version: 1,
savedAt: Date.now(),
events: {},
};
for (const [id, vid] of this.allEvents.entries()) {
payload.events[id] = vid;
}
try {
localStorage.setItem(EVENTS_CACHE_STORAGE_KEY, JSON.stringify(payload));
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to persist events cache:", err);
}
}
}
/**
* Subscribe to *all* videos (old and new) with a single subscription,
* buffering incoming events to avoid excessive DOM updates.
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
// Adjust limit/time as desired
limit: 500,
since: 0,
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
const sub = this.pool.sub(this.relays, [filter]);
const invalidDuringSub = [];
// We'll collect events here instead of processing them instantly
let eventBuffer = [];
// 1) On each incoming event, just push to the buffer
sub.on("event", (event) => {
eventBuffer.push(event);
});
// 2) Process buffered events on a setInterval (e.g., every second)
const processInterval = setInterval(() => {
if (eventBuffer.length > 0) {
// Copy and clear the buffer
const toProcess = eventBuffer.slice();
eventBuffer = [];
// Now handle each event
for (const evt of toProcess) {
try {
const video = convertEventToVideo(evt);
if (video.invalid) {
invalidDuringSub.push({ id: video.id, reason: video.reason });
continue;
}
// Store in allEvents
this.allEvents.set(evt.id, video);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
continue;
}
// Otherwise, if it's newer than what we have, update activeMap
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video); // Trigger the callback that re-renders
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
}
// Optionally, save data to local storage after processing the batch
this.saveLocalData();
}
}, 1000);
// You can still use sub.on("eose") if needed
sub.on("eose", () => {
if (isDevMode && invalidDuringSub.length > 0) {
console.warn(
`[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`,
invalidDuringSub
);
}
if (isDevMode) {
console.log(
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
);
}
});
// Return the subscription object if you need to unsub manually later
const originalUnsub =
typeof sub.unsub === "function" ? sub.unsub.bind(sub) : () => {};
let unsubscribed = false;
sub.unsub = () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
clearInterval(processInterval);
try {
return originalUnsub();
} catch (err) {
console.error("[subscribeVideos] Failed to unsub from pool:", err);
return undefined;
}
};
return sub;
}
/**
* fetchVideos => old approach
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 300,
since: 0,
};
const localAll = new Map();
// NEW: track invalid
const invalidNotes = [];
try {
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
const vid = convertEventToVideo(evt);
if (vid.invalid) {
// Accumulate if invalid
invalidNotes.push({ id: vid.id, reason: vid.reason });
} else {
// Only add if good
localAll.set(evt.id, vid);
}
}
})
);
// Merge into allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// Rebuild activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
if (video.deleted) continue;
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
// OPTIONAL: Log invalid stats
if (invalidNotes.length > 0 && isDevMode) {
console.warn(
`Skipped ${invalidNotes.length} invalid video notes:\n`,
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
);
}
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
}
}
/**
* getEventById => old approach
*/
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
return null;
}
getRequestTimeoutMs(timeoutMs) {
const candidate = Number(timeoutMs);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
const poolTimeout = Number(this.pool?.getTimeout);
if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
return Math.floor(poolTimeout);
}
return 3400;
}
normalizeCountFilter(filter) {
if (!filter || typeof filter !== "object") {
return null;
}
const normalized = {};
const toStringArray = (value) => {
if (value === undefined || value === null) {
return [];
}
const source = Array.isArray(value) ? value : [value];
const collected = [];
for (const item of source) {
if (typeof item !== "string") {
continue;
}
const trimmed = item.trim();
if (!trimmed || collected.includes(trimmed)) {
continue;
}
collected.push(trimmed);
}
return collected;
};
if (filter.kinds !== undefined) {
const kindsSource = Array.isArray(filter.kinds)
? filter.kinds
: [filter.kinds];
const normalizedKinds = [];
const seenKinds = new Set();
for (const candidate of kindsSource) {
const parsed = Number(candidate);
if (!Number.isFinite(parsed)) {
continue;
}
const normalizedValue = Math.floor(parsed);
if (seenKinds.has(normalizedValue)) {
continue;
}
seenKinds.add(normalizedValue);
normalizedKinds.push(normalizedValue);
}
if (normalizedKinds.length) {
normalized.kinds = normalizedKinds;
}
}
const ids = toStringArray(filter.ids);
if (ids.length) {
normalized.ids = ids;
}
const authors = toStringArray(filter.authors);
if (authors.length) {
normalized.authors = authors;
}
for (const [key, value] of Object.entries(filter)) {
if (!key.startsWith("#")) {
continue;
}
const tagValues = toStringArray(value);
if (tagValues.length) {
normalized[key] = tagValues;
}
}
if (filter.since !== undefined) {
const parsedSince = Number(filter.since);
if (Number.isFinite(parsedSince)) {
normalized.since = Math.floor(parsedSince);
}
}
if (filter.until !== undefined) {
const parsedUntil = Number(filter.until);
if (Number.isFinite(parsedUntil)) {
normalized.until = Math.floor(parsedUntil);
}
}
if (filter.limit !== undefined) {
const parsedLimit = Number(filter.limit);
if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
normalized.limit = Math.floor(parsedLimit);
}
}
return Object.keys(normalized).length ? normalized : null;
}
normalizeCountFilters(filters) {
if (!filters) {
return [];
}
const list = Array.isArray(filters) ? filters : [filters];
const normalized = [];
for (const candidate of list) {
const normalizedFilter = this.normalizeCountFilter(candidate);
if (normalizedFilter) {
normalized.push(normalizedFilter);
}
}
return normalized;
}
generateCountRequestId(prefix = "count") {
this.countRequestCounter += 1;
if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
this.countRequestCounter = 1;
}
const normalizedPrefix =
typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
const timestamp = Date.now().toString(36);
const counter = this.countRequestCounter.toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
}
extractCountValue(payload) {
if (typeof payload === "number") {
const value = Math.floor(payload);
return value >= 0 ? value : 0;
}
if (payload && typeof payload === "object") {
const candidate =
typeof payload.count === "number"
? payload.count
: Number(payload.count);
if (Number.isFinite(candidate)) {
const value = Math.floor(candidate);
return value >= 0 ? value : 0;
}
}
const parsed = Number(payload);
if (Number.isFinite(parsed)) {
const value = Math.floor(parsed);
return value >= 0 ? value : 0;
}
return 0;
}
async sendRawCountFrame(relayUrl, filters, options = {}) {
if (!this.pool) {
throw new Error(
"Nostr pool not initialized. Call nostrClient.init() before requesting counts."
);
}
const normalizedUrl =
typeof relayUrl === "string" ? relayUrl.trim() : "";
if (!normalizedUrl) {
throw new Error("Invalid relay URL for COUNT request.");
}
if (this.countUnsupportedRelays.has(normalizedUrl)) {
throw this.makeCountUnsupportedError(normalizedUrl);
}
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
throw new Error("At least one filter is required for a COUNT request.");
}
const requestId =
typeof options.subId === "string" && options.subId.trim()
? options.subId.trim()
: this.generateCountRequestId();
let relay;
try {
relay = await this.pool.ensureRelay(normalizedUrl);
} catch (error) {
throw new Error(`Failed to connect to relay ${normalizedUrl}`);
}
if (!relay) {
throw new Error(
`Relay ${normalizedUrl} is unavailable for COUNT requests.`
);
}
const frame = ["COUNT", requestId, ...normalizedFilters];
let countPromise;
if (
relay.openCountRequests instanceof Map &&
typeof relay.send === "function"
) {
countPromise = new Promise((resolve, reject) => {
const cleanup = () => {
if (relay.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
};
relay.openCountRequests.set(requestId, {
resolve: (value) => {
cleanup();
resolve(value);
},
reject: (error) => {
cleanup();
reject(error);
},
});
let sendResult;
try {
sendResult = relay.send(JSON.stringify(frame));
} catch (error) {
cleanup();
reject(error);
return;
}
if (sendResult && typeof sendResult.catch === "function") {
sendResult.catch((error) => {
cleanup();
reject(error);
});
}
});
} else if (typeof relay.count === "function") {
countPromise = relay.count(normalizedFilters, { id: requestId });
} else {
this.countUnsupportedRelays.add(normalizedUrl);
throw this.makeCountUnsupportedError(normalizedUrl);
}
const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
const rawResult = await withRequestTimeout(
countPromise,
timeoutMs,
() => {
if (relay?.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
},
`COUNT request timed out after ${timeoutMs}ms`
);
const countValue = this.extractCountValue(rawResult);
return ["COUNT", requestId, { count: countValue }];
}
async countEventsAcrossRelays(filters, options = {}) {
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
return { total: 0, best: null, perRelay: [] };
}
const relayList =
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
const normalizedRelayList = relayList
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
const activeRelays = [];
const precomputedEntries = [];
for (const url of normalizedRelayList) {
if (this.countUnsupportedRelays.has(url)) {
const error = this.makeCountUnsupportedError(url);
precomputedEntries.push({
url,
ok: false,
error,
unsupported: true,
});
continue;
}
activeRelays.push(url);
}
const activeResults = await Promise.all(
activeRelays.map(async (url) => {
try {
const frame = await this.sendRawCountFrame(url, normalizedFilters, {
timeoutMs: options.timeoutMs,
});
const count = this.extractCountValue(frame?.[2]);
return { url, ok: true, frame, count };
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else if (isDevMode) {
console.warn(`[nostr] COUNT request failed on ${url}:`, error);
}
return { url, ok: false, error, unsupported: isUnsupported };
}
})
);
const resultsByUrl = new Map();
for (const entry of [...precomputedEntries, ...activeResults]) {
if (entry && typeof entry.url === "string") {
resultsByUrl.set(entry.url, entry);
}
}
const perRelayResults = normalizedRelayList.map((url) => {
if (resultsByUrl.has(url)) {
return resultsByUrl.get(url);
}
return { url, ok: false };
});
let bestEstimate = null;
const perRelay = perRelayResults.map((entry) => {
if (!entry || !entry.ok) {
return entry;
}
const numericValue = Number(entry.count);
const normalizedCount =
Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
const normalizedEntry = {
...entry,
count: normalizedCount,
};
if (!Number.isFinite(numericValue) || numericValue < 0) {
normalizedEntry.rawCount = entry.count;
}
if (
!bestEstimate ||
normalizedCount > bestEstimate.count ||
(bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame)
) {
bestEstimate = {
relay: normalizedEntry.url,
count: normalizedCount,
frame: normalizedEntry.frame,
};
}
return normalizedEntry;
});
const total = bestEstimate ? bestEstimate.count : 0;
return {
total,
best: bestEstimate,
perRelay,
};
}
/**
* Ensure we have every historical revision for a given video in memory and
* return the complete set sorted newest-first. We primarily group revisions
* by their shared `videoRootId`, but fall back to the NIP-33 `d` tag when
* working with legacy notes. The explicit `d` tag fetch is important because
* relays cannot be queried by values that only exist inside the JSON
* content. Without this pass, the UI would occasionally miss mid-history
* edits that were published from other devices.
*/
async hydrateVideoHistory(video) {
if (!video || typeof video !== "object") {
return [];
}
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
const findDTagValue = (tags = []) => {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
return tag[1];
}
}
return "";
};
const targetDTag = findDTagValue(video.tags);
const collectLocalMatches = () => {
const seen = new Set();
const matches = [];
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") {
continue;
}
if (targetPubkey) {
const candidatePubkey = typeof candidate.pubkey === "string"
? candidate.pubkey.toLowerCase()
: "";
if (candidatePubkey !== targetPubkey) {
continue;
}
}
const candidateRoot =
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = findDTagValue(candidate.tags);
const sameRoot = targetRoot && candidateRoot === targetRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
// Legacy fallbacks: some old posts reused only the "d" tag without a
// canonical videoRootId. If neither identifier exists we at least keep
// the active event so the caller can surface an informative message.
const sameLegacyRoot =
!targetRoot && candidateRoot && candidateRoot === video.id;
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
if (!seen.has(candidate.id)) {
seen.add(candidate.id);
matches.push(candidate);
}
}
}
return matches;
};
let localMatches = collectLocalMatches();
const shouldFetchFromRelays =
localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
if (shouldFetchFromRelays && this.pool) {
const filter = {
kinds: [30078],
"#t": ["video"],
"#d": [targetDTag],
limit: 200,
};
if (targetPubkey) {
filter.authors = [video.pubkey];
}
try {
const perRelay = await Promise.all(
this.relays.map(async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
if (isDevMode) {
console.warn(`[nostr] History fetch failed on ${url}:`, err);
}
return [];
}
})
);
const merged = perRelay.flat();
for (const evt of merged) {
try {
const parsed = convertEventToVideo(evt);
if (!parsed.invalid) {
this.allEvents.set(evt.id, parsed);
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to convert historical event:", err);
}
}
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] hydrateVideoHistory relay fetch error:", err);
}
}
localMatches = collectLocalMatches();
}
return localMatches.sort((a, b) => b.created_at - a.created_at);
}
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
}
function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) {
if (!parsed || typeof parsed !== "object") {
return {
version: 0,
items: fallbackItems,
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
}
if (Array.isArray(parsed.items) && parsed.items.length) {
return parsed;
}
if (!Array.isArray(fallbackItems) || fallbackItems.length === 0) {
return parsed;
}
return { ...parsed, items: fallbackItems };
}
function parseWatchHistoryContentWithFallback(
content,
fallbackItems,
fallbackPayload
) {
if (!looksLikeJsonStructure(content)) {
return fallbackPayload;
}
try {
JSON.parse(content);
} catch (error) {
return fallbackPayload;
}
const parsed = parseWatchHistoryPayload(content);
return mergeWatchHistoryItemsWithFallback(parsed, fallbackItems);
}
function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) {
if (!pointerEvent || typeof pointerEvent !== "object") {
return false;
}
const tags = Array.isArray(pointerEvent.tags) ? pointerEvent.tags : [];
const normalizedCiphertext =
typeof ciphertext === "string" ? ciphertext.trim() : "";
if (!normalizedCiphertext) {
return false;
}
const hasEncryptionTag = tags.some((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return false;
}
const label =
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (label !== "encrypted") {
return false;
}
const value =
typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
return value === "nip04" || value === "nip-04";
});
if (hasEncryptionTag) {
return !looksLikeJsonStructure(normalizedCiphertext);
}
if (looksLikeJsonStructure(normalizedCiphertext)) {
return false;
}
const ivIndex = normalizedCiphertext.indexOf("?iv=");
const baseSegment =
ivIndex >= 0
? normalizedCiphertext.slice(0, ivIndex)
: normalizedCiphertext;
const ivSegment =
ivIndex >= 0 ? normalizedCiphertext.slice(ivIndex + 4) : "";
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
if (!baseSegment || !base64Regex.test(baseSegment)) {
return false;
}
if (ivSegment && !base64Regex.test(ivSegment)) {
return false;
}
return true;
}
export const nostrClient = new NostrClient();
export const recordVideoView = (...args) =>
nostrClient.recordVideoView(...args);
export const updateWatchHistoryList = (...args) =>
nostrClient.updateWatchHistoryList(...args);
export const removeWatchHistoryItem = (...args) =>
nostrClient.removeWatchHistoryItem(...args);
export const listVideoViewEvents = (...args) => {
if (typeof nostrClient.listVideoViewEvents !== "function") {
throw new Error("Video view listing is unavailable in this build.");
}
return nostrClient.listVideoViewEvents(...args);
};
export const subscribeVideoViewEvents = (...args) => {
if (typeof nostrClient.subscribeVideoViewEvents !== "function") {
throw new Error("Video view subscriptions are unavailable in this build.");
}
return nostrClient.subscribeVideoViewEvents(...args);
};
export const countVideoViewEvents = (...args) => {
if (typeof nostrClient.countVideoViewEvents !== "function") {
throw new Error("Video view counting is unavailable in this build.");
}
return nostrClient.countVideoViewEvents(...args);
};