Files
bitvid/js/nostr/watchHistory.js
T
2026-02-05 15:03:14 -05:00

2204 lines
67 KiB
JavaScript

import {
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
WATCH_HISTORY_VERSION_TAG_VALUE,
} from "../config.js";
import {
buildWatchHistoryEvent,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { publishEventToRelays } from "../nostrPublish.js";
import {
RELAY_URLS,
ensureNostrTools,
getCachedNostrTools,
} from "./toolkit.js";
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { profileCache } from "../state/profileCache.js";
import { queueSignEvent } from "./signRequestQueue.js";
import {
normalizePointerInput,
normalizePointerTag,
pointerKey,
clonePointerItem,
mergePointerDetails,
} from "../utils/pointerNormalization.js";
import { pMap } from "../utils/asyncUtils.js";
export { normalizePointerInput, pointerKey };
/**
* Domain utilities for watch-history interactions. This module owns pointer
* normalization/serialization, chunking, fingerprint hashing, persistence,
* relay publishing, fetch/decrypt flows, and exposes a manager factory that is
* dependency-injected with the nostr client hooks it needs.
*/
const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000;
const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000;
const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8;
const WATCH_HISTORY_REPUBLISH_JITTER = 0.25;
const WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER = Object.freeze([
"nip44_v2",
"nip44",
"nip04",
]);
function extractPointerItemsFromEvent(event) {
const items = [];
const seen = new Set();
if (!event || typeof event !== "object") {
return items;
}
const tags = Array.isArray(event.tags) ? event.tags : [];
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 watchedAt =
payload.watchedAt && typeof payload.watchedAt === "object" ? payload.watchedAt : {};
const processCandidate = (candidate) => {
const pointer = normalizePointerInput(candidate);
if (!pointer) {
return;
}
const key = pointerKey(pointer);
if (!key || seen.has(key)) {
return;
}
const mapValue = watchedAt[pointer.value];
if (Number.isFinite(mapValue)) {
pointer.watchedAt = Math.max(0, Math.floor(mapValue));
}
seen.add(key);
normalized.push(pointer);
};
const sourceItems = Array.isArray(payload.items) ? payload.items : [];
for (const candidate of sourceItems) {
processCandidate(candidate);
}
const sourceEvents = Array.isArray(payload.events) ? payload.events : [];
for (const candidate of sourceEvents) {
processCandidate(candidate);
}
return normalized;
}
const WATCH_HISTORY_MONTH_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
function parseWatchHistoryPayload(plaintext) {
if (typeof plaintext !== "string") {
return {
version: 0,
month: "",
items: [],
events: [],
watchedAt: {},
snapshot: "",
};
}
try {
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== "object") {
return {
version: 0,
month: "",
items: [],
events: [],
watchedAt: {},
snapshot: "",
};
}
const version = Number.isFinite(parsed.version) ? parsed.version : 0;
const month =
typeof parsed.month === "string" && WATCH_HISTORY_MONTH_PATTERN.test(parsed.month)
? parsed.month
: "";
const items = normalizePointersFromPayload(parsed);
const snapshot = typeof parsed.snapshot === "string" ? parsed.snapshot : "";
const events = Array.isArray(parsed.events)
? parsed.events.filter((id) => typeof id === "string" && id.trim())
: [];
const watchedAt =
parsed.watchedAt && typeof parsed.watchedAt === "object" ? parsed.watchedAt : {};
return { version, month, items, events, watchedAt, snapshot };
} catch (error) {
devLogger.warn("[nostr] Failed to parse watch history payload:", error);
return {
version: 0,
month: "",
items: [],
events: [],
watchedAt: {},
snapshot: "",
};
}
}
function normalizeWatchHistoryPayloadItem(entry, watchedAtMap) {
const pointer = normalizePointerInput(entry);
if (!pointer) {
return null;
}
if (entry && typeof entry === "object" && entry.metadata && typeof entry.metadata === "object") {
pointer.metadata = { ...entry.metadata };
}
const key = pointerKey(pointer);
if (!key) {
return null;
}
const watchedAtSource =
watchedAtMap instanceof Map
? watchedAtMap.get(key)
: watchedAtMap && typeof watchedAtMap === "object"
? watchedAtMap[key]
: null;
const watchedAt = Number.isFinite(watchedAtSource)
? Math.max(0, Math.floor(watchedAtSource))
: Number.isFinite(pointer.watchedAt)
? Math.max(0, Math.floor(pointer.watchedAt))
: undefined;
return {
pointer,
id: pointer.value,
watchedAt,
};
}
export function buildWatchHistoryPayload(
monthString,
events,
watchedAtMap,
maxBytes = WATCH_HISTORY_PAYLOAD_MAX_BYTES,
) {
const normalizedMonth = typeof monthString === "string" ? monthString.trim() : "";
const payload = {
version: 2,
month: normalizedMonth,
items: [],
watchedAt: {},
snapshot: "",
};
const included = [];
const skipped = [];
const maxBytesValue =
Number.isFinite(maxBytes) && maxBytes > 0 ? Math.floor(maxBytes) : 0;
for (const entry of Array.isArray(events) ? events : []) {
const normalized = normalizeWatchHistoryPayloadItem(entry, watchedAtMap);
if (!normalized) {
continue;
}
const nextItems = [...payload.items, normalized.id];
const sizeEntry = { ...normalized.pointer };
if (Number.isFinite(normalized.watchedAt)) {
sizeEntry.watchedAt = normalized.watchedAt;
}
const nextWatchedAt = { ...payload.watchedAt };
if (Number.isFinite(normalized.watchedAt)) {
nextWatchedAt[normalized.id] = normalized.watchedAt;
}
if (maxBytesValue) {
const entrySize = JSON.stringify(sizeEntry).length;
if (entrySize > maxBytesValue) {
skipped.push(normalized.pointer);
continue;
}
const candidatePayload = {
...payload,
items: nextItems,
watchedAt: nextWatchedAt,
};
const serialized = JSON.stringify(candidatePayload);
if (serialized.length > maxBytesValue) {
skipped.push(normalized.pointer);
continue;
}
}
payload.items = nextItems;
payload.watchedAt = nextWatchedAt;
included.push(normalized.pointer);
}
return { payload, included, skipped };
}
export function normalizeActorKey(actor) {
if (typeof actor !== "string") {
return "";
}
const trimmed = actor.trim();
if (!trimmed) {
return "";
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
const toolkit = getCachedNostrTools();
const decoder =
(typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null) ||
toolkit?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
return decoded.data.toLowerCase();
}
if (decoded?.type === "npub" && typeof decoded.data?.pubkey === "string") {
return decoded.data.pubkey.toLowerCase();
}
} catch (error) {
devLogger.warn("[nostr] Failed to decode actor key:", error);
}
}
return trimmed.toLowerCase();
}
function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_ITEMS) {
const seen = new Map();
if (Array.isArray(rawItems)) {
for (const candidate of rawItems) {
const pointer = normalizePointerInput(candidate);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key) {
continue;
}
const existing = seen.get(key);
if (!existing) {
seen.set(key, pointer);
continue;
}
const currentWatched = Number.isFinite(existing.watchedAt) ? existing.watchedAt : 0;
const incomingWatched = Number.isFinite(pointer.watchedAt) ? pointer.watchedAt : 0;
if (incomingWatched > currentWatched) {
mergePointerDetails(pointer, existing);
pointer.watchedAt = incomingWatched;
pointer.session = existing.session === true || pointer.session === true;
seen.set(key, pointer);
continue;
}
mergePointerDetails(existing, pointer);
if (pointer.session === true) {
existing.session = true;
}
}
}
const deduped = Array.from(seen.values());
deduped.sort((a, b) => {
const watchedA = Number.isFinite(a?.watchedAt) ? a.watchedAt : 0;
const watchedB = Number.isFinite(b?.watchedAt) ? b.watchedAt : 0;
if (watchedA !== watchedB) {
return watchedB - watchedA;
}
const keyA = pointerKey(a);
const keyB = pointerKey(b);
if (keyA < keyB) {
return -1;
}
if (keyA > keyB) {
return 1;
}
return 0;
});
const limited = (!Number.isFinite(maxItems) || maxItems <= 0)
? deduped
: deduped.slice(0, Math.max(0, Math.floor(maxItems)));
const buckets = {};
for (const item of limited) {
const watchedAt = Number.isFinite(item.watchedAt) ? item.watchedAt : 0;
const date = new Date(watchedAt * 1000);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const key = (watchedAt > 0) ? `${year}-${month}` : "1970-01";
if (!buckets[key]) {
buckets[key] = [];
}
buckets[key].push(item);
}
return buckets;
}
function sanitizeWatchHistoryMetadata(metadata) {
return {};
}
function serializeWatchHistoryItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return "[]";
}
const normalized = items
.map((item) => {
const type = item?.type === "a" ? "a" : "e";
const value = typeof item?.value === "string" ? item.value : "";
if (!type || !value) {
return null;
}
const relay =
typeof item?.relay === "string" && item.relay.trim()
? item.relay.trim()
: undefined;
const watchedAt = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(item.watchedAt))
: undefined;
const payload = { type, value };
if (relay) {
payload.relay = relay;
}
if (watchedAt !== undefined) {
payload.watchedAt = watchedAt;
}
const resumeAt = Number.isFinite(item?.resumeAt)
? Math.max(0, Math.floor(item.resumeAt))
: undefined;
if (resumeAt !== undefined) {
payload.resumeAt = resumeAt;
}
if (item?.completed === true) {
payload.completed = true;
}
return payload;
})
.filter(Boolean);
return JSON.stringify(normalized);
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function computeWatchHistoryFingerprintForItems(itemsOrBuckets) {
// Check if buckets
let flatItems = [];
if (Array.isArray(itemsOrBuckets)) {
flatItems = [...itemsOrBuckets];
} else if (itemsOrBuckets && typeof itemsOrBuckets === "object") {
flatItems = Object.keys(itemsOrBuckets)
.sort()
.reverse()
.map((k) => itemsOrBuckets[k])
.flat();
}
// Sort for deterministic fingerprint
flatItems.sort((a, b) => {
const keyA = pointerKey(a);
const keyB = pointerKey(b);
if (keyA < keyB) return -1;
if (keyA > keyB) return 1;
return (b.watchedAt || 0) - (a.watchedAt || 0);
});
const serialized = serializeWatchHistoryItems(flatItems);
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
if (encoder && window?.crypto?.subtle?.digest) {
try {
const data = encoder.encode(serialized);
const digest = await window.crypto.subtle.digest("SHA-256", data);
return bytesToHex(new Uint8Array(digest));
} catch (error) {
devLogger.warn("[nostr] Failed to hash watch history fingerprint:", error);
}
}
return `fallback:${serialized}`;
}
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 hexToBytesCompat(hex, tools = null) {
if (typeof hex !== "string") {
throw new Error("Invalid hex input.");
}
const trimmed = hex.trim();
if (!trimmed || trimmed.length % 2 !== 0) {
throw new Error("Invalid hex input.");
}
if (tools?.utils && typeof tools.utils.hexToBytes === "function") {
return tools.utils.hexToBytes(trimmed);
}
const bytes = new Uint8Array(trimmed.length / 2);
for (let index = 0; index < trimmed.length; index += 2) {
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
if (Number.isNaN(byte)) {
throw new Error("Invalid hex input.");
}
bytes[index / 2] = byte;
}
return bytes;
}
function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) {
if (!tools || !privateKeyHex || !targetPubkeyHex) {
return null;
}
const normalizedPrivateKey =
typeof privateKeyHex === "string" && privateKeyHex.trim()
? privateKeyHex.trim().toLowerCase()
: "";
const normalizedTarget =
typeof targetPubkeyHex === "string" && targetPubkeyHex.trim()
? targetPubkeyHex.trim().toLowerCase()
: "";
if (!normalizedPrivateKey || !normalizedTarget) {
return null;
}
let privateKeyBytes;
try {
privateKeyBytes = hexToBytesCompat(normalizedPrivateKey, tools);
} catch (_) {
return null;
}
const nip44 = tools?.nip44 && typeof tools.nip44 === "object" ? tools.nip44 : null;
const suite = {};
const nip44v2 = nip44?.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (
nip44v2 &&
typeof nip44v2.encrypt === "function" &&
typeof nip44v2.decrypt === "function" &&
typeof nip44v2?.utils?.getConversationKey === "function"
) {
let cachedKey = null;
const ensureKey = () => {
if (!cachedKey) {
cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget);
}
return cachedKey;
};
suite.v2 = {
encrypt: (plaintext) => nip44v2.encrypt(plaintext, ensureKey()),
decrypt: (ciphertext) => nip44v2.decrypt(ciphertext, ensureKey()),
};
}
const legacyGetConversationKey = (() => {
if (typeof nip44?.getConversationKey === "function") {
return nip44.getConversationKey.bind(nip44);
}
if (typeof nip44?.utils?.getConversationKey === "function") {
return nip44.utils.getConversationKey.bind(nip44.utils);
}
return null;
})();
if (
typeof nip44?.encrypt === "function" &&
typeof nip44?.decrypt === "function" &&
typeof legacyGetConversationKey === "function"
) {
let cachedKey = null;
const ensureLegacyKey = () => {
if (!cachedKey) {
cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget);
}
return cachedKey;
};
suite.legacy = {
encrypt: (plaintext) => nip44.encrypt(plaintext, ensureLegacyKey()),
decrypt: (ciphertext) => nip44.decrypt(ciphertext, ensureLegacyKey()),
};
}
if (!suite.v2 && !suite.legacy) {
return null;
}
return suite;
}
function normalizeEncryptionTagValue(value) {
if (typeof value !== "string") {
return "";
}
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "nip04":
case "nip-04":
case "nip4":
return "nip04";
case "nip44_v2":
case "nip44-v2":
case "nip44v2":
return "nip44_v2";
case "nip44_v1":
case "nip44-v1":
case "nip44v1":
case "nip44":
return "nip44";
default:
return "";
}
}
function extractWatchHistoryEncryptionHints(event, ciphertext) {
const hints = [];
const pushUnique = (hint) => {
if (hint && !hints.includes(hint)) {
hints.push(hint);
}
};
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (label !== "encrypted") {
continue;
}
const normalizedValue = normalizeEncryptionTagValue(tag[1]);
if (normalizedValue) {
pushUnique(normalizedValue);
}
}
if (!hints.length && isNip04EncryptedWatchHistoryEvent(event, ciphertext)) {
pushUnique("nip04");
}
return hints;
}
function determineWatchHistoryDecryptionOrder(
event,
ciphertext,
availableSchemes = [],
) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
const hints = extractWatchHistoryEncryptionHints(event, ciphertext);
const aliasMap = {
nip04: ["nip04"],
nip44: ["nip44", "nip44_v2"],
nip44_v2: ["nip44_v2", "nip44"],
};
for (const hint of hints) {
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
for (const candidate of candidates) {
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
prioritized.push(candidate);
break;
}
}
}
for (const fallback of WATCH_HISTORY_ENCRYPTION_FALLBACK_ORDER) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
async function resolveNostrToolkit(deps = {}, cacheRef = null) {
if (cacheRef?.current) {
return cacheRef.current;
}
const ensureToolkit =
typeof deps.ensureNostrTools === "function" ? deps.ensureNostrTools : ensureNostrTools;
if (ensureToolkit) {
try {
const ensured = await ensureToolkit();
if (ensured) {
if (cacheRef) {
cacheRef.current = ensured;
}
return ensured;
}
} catch (error) {
devLogger.warn("[nostr] Failed to resolve nostr-tools for watch history:", error);
}
}
const readCachedToolkit =
typeof deps.getCachedNostrTools === "function"
? deps.getCachedNostrTools
: getCachedNostrTools;
if (readCachedToolkit) {
try {
const cached = await readCachedToolkit();
if (cached) {
if (cacheRef) {
cacheRef.current = cached;
}
return cached;
}
} catch (error) {
devLogger.warn("[nostr] Failed to read cached nostr-tools for watch history:", error);
}
}
return cacheRef?.current || null;
}
function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) {
if (!parsed || typeof parsed !== "object") {
return {
version: 0,
month: "",
items: fallbackItems,
snapshot: "",
};
}
const fallbackMap = new Map();
for (const item of Array.isArray(fallbackItems) ? fallbackItems : []) {
const key = pointerKey(item);
if (key) {
fallbackMap.set(item.value, item);
}
}
const merged = [];
const sourceItems = Array.isArray(parsed.items) ? parsed.items : [];
for (const item of sourceItems) {
const fallback = fallbackMap.get(item.value);
const pointer = fallback ? clonePointerItem(fallback) : item;
if (fallback) {
mergePointerDetails(pointer, item);
}
merged.push(pointer);
}
if (merged.length === 0 && Array.isArray(fallbackItems) && fallbackItems.length > 0) {
return { ...parsed, items: fallbackItems };
}
return {
version: parsed.version,
month: parsed.month,
items: merged,
snapshot: parsed.snapshot,
};
}
export 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);
}
export 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=");
if (ivIndex < 0) {
return false;
}
const baseSegment = normalizedCiphertext.slice(0, ivIndex);
const ivSegment = 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;
}
class WatchHistoryManager {
constructor(deps) {
this.deps = {
ensureNostrTools,
getCachedNostrTools,
...(deps || {}),
};
this.cache = new Map();
this.storage = null;
this.republishTimers = new Map();
this.refreshPromises = new Map();
this.cacheTtlMs = 0;
this.fingerprints = new Map();
this.lastCreatedAt = 0;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.clear();
} else if (event === "runtimeCleared") {
const activePubkey = typeof this.deps.getActivePubkey === "function"
? this.deps.getActivePubkey()
: "";
const normalized = normalizeActorKey(detail.pubkey);
const active = normalizeActorKey(activePubkey);
if (normalized && active && normalized === active) {
this.cache.delete(normalized);
}
}
});
}
getCacheTtlMs() {
if (Number.isFinite(this.cacheTtlMs) && this.cacheTtlMs > 0) {
return this.cacheTtlMs;
}
const policyTtl = CACHE_POLICIES[NOTE_TYPES.WATCH_HISTORY]?.ttl;
const configured = Number(WATCH_HISTORY_CACHE_TTL_MS);
// Prefer config if set (for backward compat or overrides), otherwise policy
let resolved =
Number.isFinite(configured) && configured > 0
? Math.floor(configured)
: Number.isFinite(policyTtl) && policyTtl > 0
? policyTtl
: 24 * 60 * 60 * 1000;
this.cacheTtlMs = resolved;
return resolved;
}
getStorage(actorInput = null) {
const actorKey = normalizeActorKey(actorInput) || normalizeActorKey(this.deps.getActivePubkey?.());
if (!actorKey) {
return { actors: {} }; // Return empty structure if no actor resolved
}
const cached = profileCache.get(NOTE_TYPES.WATCH_HISTORY);
if (!cached) {
return { actors: {} };
}
// Wrap single entry in actors map for compatibility
return {
actors: {
[actorKey]: cached
}
};
}
persistEntry(actorInput, entry) {
const actorKey = normalizeActorKey(actorInput);
if (!actorKey || !entry) {
return;
}
// Entry can have `items` (flat) or `records` (buckets). We standardize to records.
let records = {};
if (entry.records && typeof entry.records === "object") {
const flat = Object.values(entry.records).flat();
records = canonicalizeWatchHistoryItems(flat, WATCH_HISTORY_MAX_ITEMS);
} else if (Array.isArray(entry.items)) {
records = canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS);
}
const items = Object.values(records).flat();
const snapshotId = typeof entry.snapshotId === "string" ? entry.snapshotId : "";
const fingerprint = typeof entry.fingerprint === "string" ? entry.fingerprint : "";
const savedAt = Number.isFinite(entry.savedAt) && entry.savedAt > 0 ? entry.savedAt : Date.now();
const actorValue =
typeof entry.actor === "string" && entry.actor.trim()
? entry.actor.trim()
: actorInput || actorKey;
const data = {
actor: actorValue,
snapshotId,
fingerprint,
savedAt,
records,
items,
metadata: {},
};
// profileCache keys by active profile, so we just set the data for "watchHistory"
profileCache.set(NOTE_TYPES.WATCH_HISTORY, data);
}
cancelRepublish(taskId = null) {
if (!taskId) {
for (const entry of this.republishTimers.values()) {
if (entry && typeof entry.timer === "number") {
clearTimeout(entry.timer);
} else if (entry && entry.timer) {
clearTimeout(entry.timer);
} else if (typeof entry === "number") {
clearTimeout(entry);
}
}
this.republishTimers.clear();
return;
}
const key = typeof taskId === "string" ? taskId.trim() : "";
if (!key) {
return;
}
const entry = this.republishTimers.get(key);
if (entry && typeof entry.timer === "number") {
clearTimeout(entry.timer);
} else if (entry && entry.timer) {
clearTimeout(entry.timer);
} else if (typeof entry === "number") {
clearTimeout(entry);
}
this.republishTimers.delete(key);
}
scheduleRepublish(taskId, operation, options = {}) {
const key = typeof taskId === "string" ? taskId.trim() : "";
if (!key || typeof operation !== "function") {
return;
}
const onSchedule =
typeof options?.onSchedule === "function" ? options.onSchedule : null;
const previous = this.republishTimers.get(key);
if (previous && typeof previous.timer === "number") {
clearTimeout(previous.timer);
} else if (previous && previous?.timer) {
clearTimeout(previous.timer);
}
const requestedAttempt = Number.isFinite(options?.attempt)
? Math.max(0, Math.floor(options.attempt))
: 0;
const baseAttempt = Number.isFinite(previous?.attempt)
? Math.max(previous.attempt + 1, requestedAttempt)
: requestedAttempt;
const attempt = Math.min(baseAttempt, WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS);
if (attempt > WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
return;
}
let delay = 0;
if (attempt === 0) {
delay = 10;
} else {
const exponentialDelay =
WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS * Math.pow(2, attempt);
const cappedDelay = Math.min(
exponentialDelay,
WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS,
);
const jitter =
Math.random() * cappedDelay * WATCH_HISTORY_REPUBLISH_JITTER;
delay = Math.max(
WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS,
Math.floor(cappedDelay + jitter),
);
}
if (onSchedule) {
try {
onSchedule({ taskId: key, attempt: attempt + 1, delay });
} catch (error) {
devLogger.warn(
`[nostr] Failed to notify watch history republish schedule for ${key}:`,
error,
);
}
}
const timer = setTimeout(async () => {
this.republishTimers.delete(key);
try {
const result = await operation(attempt + 1);
if (!result || result.ok !== true) {
if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
this.scheduleRepublish(key, operation, {
attempt: attempt + 1,
onSchedule,
});
} else {
devLogger.warn(
`[nostr] Watch history republish aborted for ${key}: max attempts reached.`,
);
}
} else {
this.cancelRepublish(key);
}
} catch (error) {
devLogger.warn("[nostr] Watch history republish attempt failed:", error);
if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) {
this.scheduleRepublish(key, operation, {
attempt: attempt + 1,
onSchedule,
});
}
}
}, delay);
this.republishTimers.set(key, {
timer,
attempt,
operation,
});
return { attempt: attempt + 1, delay };
}
async getFingerprint(actorInput, itemsOverride = null) {
const resolvedActor =
typeof actorInput === "string" && actorInput.trim()
? actorInput.trim()
: typeof this.deps.getActivePubkey === "function"
? this.deps.getActivePubkey()
: "";
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return "";
}
let itemsOrRecords = itemsOverride;
if (!itemsOrRecords) {
const cacheEntry = this.cache.get(actorKey) || this.getStorage().actors?.[actorKey];
itemsOrRecords = cacheEntry?.records || cacheEntry?.items || [];
}
// Convert flat array to buckets if needed, or use as is if already bucketed/records
let buckets = itemsOrRecords;
if (Array.isArray(itemsOrRecords)) {
buckets = canonicalizeWatchHistoryItems(itemsOrRecords, WATCH_HISTORY_MAX_ITEMS);
}
const fingerprint = await computeWatchHistoryFingerprintForItems(buckets);
const previous = this.fingerprints.get(actorKey);
if (previous && previous !== fingerprint) {
devLogger.info(`[nostr] Watch history fingerprint changed for ${actorKey}.`);
}
this.fingerprints.set(actorKey, fingerprint);
return fingerprint;
}
ensureBackgroundRefresh(actorInput = null) {
const resolvedActor =
typeof actorInput === "string" && actorInput.trim()
? actorInput.trim()
: typeof this.deps.getActivePubkey === "function"
? this.deps.getActivePubkey()
: this.deps.getSessionActor?.()?.pubkey || "";
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return Promise.resolve({ pointerEvent: null, items: [], snapshotId: "" });
}
if (this.refreshPromises.has(actorKey)) {
return this.refreshPromises.get(actorKey);
}
const promise = (async () => {
const fetchResult = await this.fetch(resolvedActor, {
forceRefresh: true,
});
if (fetchResult.pointerEvent) {
return fetchResult;
}
const storageEntry = this.getStorage().actors?.[actorKey];
const records = storageEntry?.records || {};
const flatItems = Object.values(records).flat();
if (!flatItems.length) {
return fetchResult;
}
// We publish all records
const publishResult = await this.publishRecords(records, {
actorPubkey: resolvedActor,
source: "background-refresh",
});
const fingerprint = await this.getFingerprint(resolvedActor, records);
const entry = {
actor: resolvedActor,
records,
items: flatItems, // Keep items for compat if needed, or just records
snapshotId: publishResult.snapshotId || storageEntry?.snapshotId || "",
pointerEvent: publishResult.pointerEvent || null,
chunkEvents: [],
savedAt: Date.now(),
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
if (!publishResult.ok && publishResult.retryable) {
// Schedule republish for failed months?
// Simple retry for now
this.scheduleRepublish("background-refresh", async (attempt) =>
this.publishRecords(records, {
actorPubkey: resolvedActor,
attempt,
source: "background-refresh",
}),
);
}
return {
pointerEvent: entry.pointerEvent,
items: flatItems,
snapshotId: entry.snapshotId,
};
})()
.catch((error) => {
devLogger.warn("[nostr] Watch history background refresh failed:", error);
throw error;
})
.finally(() => {
this.refreshPromises.delete(actorKey);
});
this.refreshPromises.set(actorKey, promise);
return promise;
}
async publishRecords(records, options = {}) {
// High level wrapper to publish multiple months
// records is { "YYYY-MM": [items] }
let allOk = true;
let anyRetryable = false;
const months = Object.keys(records).sort();
const results = await pMap(
months,
async (month) => {
const items = records[month];
// Ideally we check if this month changed before publishing.
// For now, we rely on the caller or just publish.
// In a real optimized system we'd track dirty flags per month.
return this.publishMonthRecord(month, items, options);
},
{ concurrency: 3 },
);
for (const res of results) {
if (!res.ok) {
allOk = false;
if (res.retryable) anyRetryable = true;
}
}
return {
ok: allOk,
retryable: anyRetryable,
results,
items: results.flatMap(r => r.items || []),
snapshotId: "", // No single snapshot ID
// Return last event as pointerEvent? Or null?
pointerEvent: results.length > 0 ? results[results.length-1].pointerEvent : null
};
}
async publishSnapshot(rawItems, options = {}) {
// Compatibility wrapper
const buckets = canonicalizeWatchHistoryItems(rawItems, WATCH_HISTORY_MAX_ITEMS);
const result = await this.publishRecords(buckets, options);
// Ensure items are returned for compatibility
if (!result.items && result.results) {
result.items = result.results.flatMap(r => r.items || []);
}
const actorCandidates = [options.actorPubkey];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
const sessionActor = this.deps.getSessionActor?.();
if (sessionActor?.pubkey) {
actorCandidates.push(sessionActor.pubkey);
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor && typeof this.deps.ensureSessionActor === "function") {
resolvedActor = await this.deps.ensureSessionActor();
}
const actorKey = normalizeActorKey(resolvedActor);
if (actorKey) {
const flatItems = Array.isArray(Object.values(buckets))
? Object.values(buckets).flat()
: [];
const fingerprint = await this.getFingerprint(resolvedActor, buckets);
const entry = {
actor: resolvedActor,
records: buckets,
items: Array.isArray(flatItems) ? flatItems : [],
snapshotId: result.snapshotId || "",
pointerEvent: result.pointerEvent || null,
savedAt: Date.now(),
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
}
return result;
}
async publishMonthRecord(monthIdentifier, items, options = {}) {
if (!items || !items.length) return { ok: true };
const pool = typeof this.deps.getPool === "function" ? this.deps.getPool() : null;
if (!pool) {
return { ok: false, error: "nostr-uninitialized", retryable: false };
}
const actorCandidates = [options.actorPubkey];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
const sessionActor = this.deps.getSessionActor?.();
if (sessionActor?.pubkey) {
actorCandidates.push(sessionActor.pubkey);
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor && typeof this.deps.ensureSessionActor === "function") {
resolvedActor = await this.deps.ensureSessionActor();
}
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return { ok: false, error: "missing-actor", retryable: false };
}
const actorPubkey = resolvedActor || actorKey;
const normalizedLogged = normalizeActorKey(
typeof this.deps.getActivePubkey === "function" ? this.deps.getActivePubkey() : "",
);
const signer = this.deps.resolveActiveSigner?.(actorKey) || null;
const canUseActiveSignerSign =
normalizedLogged &&
normalizedLogged === actorKey &&
signer &&
typeof signer.signEvent === "function";
const activeSigner = canUseActiveSignerSign ? signer : null;
let privateKey = "";
if (!canUseActiveSignerSign) {
if (!sessionActor || sessionActor.pubkey !== actorKey) {
const ensured = await this.deps.ensureSessionActor?.();
if (normalizeActorKey(ensured) !== actorKey) {
return { ok: false, error: "session-actor-mismatch", retryable: false };
}
}
const currentSession = this.deps.getSessionActor?.();
if (!currentSession || currentSession.pubkey !== actorKey) {
return { ok: false, error: "session-actor-missing", retryable: false };
}
privateKey = currentSession.privateKey;
if (!privateKey) {
return { ok: false, error: "missing-session-key", retryable: false };
}
}
// Sort items latest first for payload construction
items.sort((a, b) => (b.watchedAt || 0) - (a.watchedAt || 0));
const { payload, included, skipped } = buildWatchHistoryPayload(
monthIdentifier,
items,
options.watchedAtMap || null
);
const relayCandidates = [];
if (Array.isArray(options.relays) && options.relays.length) {
relayCandidates.push(...options.relays);
} else if (typeof this.deps.getWriteRelays === "function") {
const write = this.deps.getWriteRelays();
if (Array.isArray(write) && write.length) {
relayCandidates.push(...write);
}
}
if (!relayCandidates.length && typeof this.deps.getRelayFallback === "function") {
const fallback = this.deps.getRelayFallback();
if (Array.isArray(fallback) && fallback.length) {
relayCandidates.push(...fallback);
}
}
let relays = Array.from(new Set(relayCandidates));
if (!relays.length) {
relays = Array.from(RELAY_URLS);
}
devLogger.info("[nostr] Preparing to publish watch history month.", {
actor: actorKey,
monthIdentifier,
itemCount: items.length,
payloadCount: Array.isArray(payload.events) ? payload.events.length : 0,
relaysRequested: relays,
attempt: options.attempt || 0,
source: options.source || "unknown",
});
const createdAtBase = Math.max(Math.floor(Date.now() / 1000), this.lastCreatedAt + 1);
const signEvent = async (event) => {
if (activeSigner) {
return queueSignEvent(activeSigner, event, {
timeoutMs: options.timeoutMs,
});
}
return this.deps.signEventWithPrivateKey(event, privateKey);
};
let createdAtCursor = createdAtBase;
const formatRelayStatus = (results = []) => {
const normalized = Array.isArray(results) ? results : [];
const statuses = [];
const byUrl = new Map();
for (const entry of normalized) {
const url = typeof entry?.url === "string" ? entry.url : "";
if (!url) {
continue;
}
const reasonValue = (() => {
const error = entry?.error;
if (!error) {
return null;
}
if (error instanceof Error) {
return error.message || "publish failed";
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
try {
return JSON.stringify(error);
} catch (_) {
return String(error);
}
})();
byUrl.set(url, {
url,
success: !!entry?.success,
reason: reasonValue,
});
}
for (const relayUrl of relays) {
const existing = byUrl.get(relayUrl);
if (existing) {
statuses.push(existing);
} else {
statuses.push({ url: relayUrl, success: false, reason: "no-result" });
}
}
return statuses;
};
const pointerTags = included.map((pointer) => {
const tag = [pointer.type === "a" ? "a" : "e", pointer.value];
if (pointer.relay) {
tag.push(pointer.relay);
}
return tag;
});
const plaintext = JSON.stringify(payload);
let content = plaintext;
const encryptionTags = [];
if (activeSigner) {
if (this.deps.shouldRequestExtensionPermissions?.(activeSigner)) {
await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS);
}
const encryptors = [];
if (typeof activeSigner.nip44Encrypt === "function") {
encryptors.push({ scheme: "nip44", encrypt: activeSigner.nip44Encrypt });
}
if (typeof activeSigner.nip04Encrypt === "function") {
encryptors.push({ scheme: "nip04", encrypt: activeSigner.nip04Encrypt });
}
for (const encryptor of encryptors) {
try {
const encrypted = await encryptor.encrypt(actorPubkey, plaintext);
if (typeof encrypted === "string" && encrypted) {
content = encrypted;
encryptionTags.push(["encrypted", encryptor.scheme]);
break;
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to encrypt watch history payload with signer:",
error,
);
}
}
}
if (!encryptionTags.length && privateKey) {
try {
const tools =
(await this.deps.ensureNostrTools?.()) || this.deps.getCachedNostrTools?.();
if (tools?.nip04 && typeof tools.nip04.encrypt === "function") {
const encrypted = await tools.nip04.encrypt(
privateKey,
actorPubkey,
plaintext,
);
if (typeof encrypted === "string" && encrypted) {
content = encrypted;
encryptionTags.push(["encrypted", "nip04"]);
}
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to encrypt watch history payload with fallback tools:",
error,
);
}
}
if (!encryptionTags.length) {
devLogger.warn("[nostr] Aborting watch history publish: mandatory encryption failed.");
return { ok: false, error: "encryption-failed", retryable: true };
}
const event = buildWatchHistoryEvent({
pubkey: actorPubkey,
created_at: createdAtCursor,
monthIdentifier,
pointerTags,
content,
});
if (encryptionTags.length) {
event.tags.push(...encryptionTags);
}
createdAtCursor += 1;
let signedEvent;
try {
signedEvent = await signEvent(event);
} catch (error) {
devLogger.warn("[nostr] Failed to sign watch history event:", error);
return { ok: false, error: "signing-failed", retryable: false };
}
const publishResults = await publishEventToRelays(pool, relays, signedEvent);
const relayStatus = formatRelayStatus(publishResults);
const acceptedCount = relayStatus.filter((entry) => entry.success).length;
let anyRejected = false;
let anyPartial = false;
if (acceptedCount === 0) {
anyRejected = true;
devLogger.warn("[nostr] Watch history event rejected by all relays:", publishResults);
} else {
const logMessage = acceptedCount === relays.length ? "accepted" : "partially accepted";
if (acceptedCount === relays.length) {
devLogger.info(
`[nostr] Watch history event accepted by ${acceptedCount}/${relays.length} relay(s).`,
);
} else {
anyPartial = true;
devLogger.warn(
`[nostr] Watch history event ${logMessage} by ${acceptedCount}/${relays.length} relay(s).`,
publishResults,
);
}
}
this.lastCreatedAt = createdAtCursor;
const partialAcceptance = anyPartial;
const success = acceptedCount === relays.length && !anyRejected;
let errorCode = null;
if (!success) {
if (anyRejected) {
errorCode = "publish-rejected";
} else if (partialAcceptance) {
errorCode = "partial-relay-acceptance";
}
}
const result = {
ok: success,
retryable: !success,
actor: actorPubkey,
monthIdentifier,
items: items,
pointerEvent: signedEvent,
publishResults: {
pointer: publishResults,
relayStatus: {
pointer: relayStatus,
},
},
skippedCount: skipped.length,
source: options.source || "manual",
partial: partialAcceptance,
};
if (!success && errorCode) {
result.error = errorCode;
}
devLogger.info("[nostr] Watch history month publish result.", {
actor: actorKey,
monthIdentifier,
success,
partialAcceptance,
error: result.error || null,
acceptedCount,
});
return result;
}
async updateList(rawItems = [], options = {}) {
const actorCandidates = [options.actorPubkey];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor && typeof this.deps.ensureSessionActor === "function") {
resolvedActor = await this.deps.ensureSessionActor();
}
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return { ok: false, error: "missing-actor" };
}
const storage = this.getStorage();
const cachedEntry = this.cache.get(actorKey) || storage.actors?.[actorKey] || {};
// Existing items - try to get records, fallback to items
let existingItems = [];
if (cachedEntry.records && typeof cachedEntry.records === "object") {
existingItems = Object.keys(cachedEntry.records)
.sort()
.reverse()
.map((k) => cachedEntry.records[k])
.flat();
} else if (Array.isArray(cachedEntry.items)) {
existingItems = cachedEntry.items;
}
const incomingItems = Array.isArray(rawItems) ? rawItems : [];
const combined =
options.replace === true ? incomingItems : [...incomingItems, ...existingItems];
// Bucket everything
const records = canonicalizeWatchHistoryItems(
combined,
WATCH_HISTORY_MAX_ITEMS,
);
// Ensure empty months are preserved so they get cleared on relays
if (cachedEntry.records && typeof cachedEntry.records === "object") {
const oldKeys = Object.keys(cachedEntry.records);
for (const oldKey of oldKeys) {
if (!records[oldKey]) {
records[oldKey] = [];
}
}
}
// Calculate fingerprint
const fingerprint = await this.getFingerprint(resolvedActor, records);
const flatItems = Object.values(records).flat();
devLogger.info("[nostr] Updating watch history list.", {
actor: resolvedActor,
incomingItemCount: incomingItems.length,
finalItemCount: flatItems.length,
replace: options.replace === true,
});
// Publish
const publishResult = await this.publishRecords(records, {
actorPubkey: resolvedActor,
snapshotId: options.snapshotId || cachedEntry.snapshotId,
attempt: options.attempt || 0,
});
devLogger.info("[nostr] Watch history list publish attempt finished.", {
actor: resolvedActor,
snapshotId: publishResult.snapshotId || null,
success: !!publishResult.ok,
retryable: !!publishResult.retryable,
});
const entry = {
actor: resolvedActor,
records,
items: flatItems,
snapshotId: publishResult.snapshotId || cachedEntry.snapshotId || "",
pointerEvent: publishResult.pointerEvent || cachedEntry.pointerEvent || null,
savedAt: Date.now(),
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
if (!publishResult.ok && publishResult.retryable) {
// Retry full publish
this.scheduleRepublish("update-list", async (attempt) =>
this.publishRecords(records, {
actorPubkey: resolvedActor,
attempt,
}),
);
} else {
this.cancelRepublish("update-list");
}
return publishResult;
}
async removeItem(pointerInput, options = {}) {
const pointer = normalizePointerInput(pointerInput);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const actorCandidates = [options.actorPubkey];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor && typeof this.deps.ensureSessionActor === "function") {
resolvedActor = await this.deps.ensureSessionActor();
}
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return { ok: false, error: "missing-actor" };
}
const existingEntry =
this.cache.get(actorKey) || this.getStorage().actors?.[actorKey] || {};
let existingItems = [];
if (existingEntry.records) {
existingItems = Object.values(existingEntry.records).flat();
} else if (Array.isArray(existingEntry.items)) {
existingItems = existingEntry.items;
}
const targetKey = pointerKey(pointer);
const filtered = existingItems.filter((item) => pointerKey(item) !== targetKey);
if (filtered.length === existingItems.length) {
return {
ok: true,
skipped: true,
snapshotId: existingEntry.snapshotId || "",
};
}
return this.updateList(filtered, {
...options,
actorPubkey: resolvedActor,
replace: true,
});
}
async fetch(actorInput, options = {}) {
const actorCandidates = [actorInput];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
const sessionActor = this.deps.getSessionActor?.();
if (sessionActor?.pubkey) {
actorCandidates.push(sessionActor.pubkey);
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor) {
resolvedActor = sessionActor?.pubkey || "";
}
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return { pointerEvent: null, items: [], snapshotId: "" };
}
const actorKeyIsHex = /^[0-9a-f]{64}$/.test(actorKey);
const activePubkey = typeof this.deps.getActivePubkey === "function" ? this.deps.getActivePubkey() : "";
const normalizedLogged = normalizeActorKey(activePubkey);
let signer = this.deps.resolveActiveSigner?.(actorKey) || null;
if (!signer && activePubkey && normalizedLogged === actorKey) {
signer = this.deps.resolveActiveSigner?.(activePubkey);
}
const canUseActiveSignerDecrypt =
normalizedLogged &&
normalizedLogged === actorKey &&
signer &&
(typeof signer.nip04Decrypt === "function" || typeof signer.nip44Decrypt === "function");
const decryptSigner = canUseActiveSignerDecrypt ? signer : null;
if (decryptSigner && this.deps.shouldRequestExtensionPermissions?.(decryptSigner)) {
await this.deps.ensureExtensionPermissions?.(DEFAULT_NIP07_PERMISSION_METHODS);
}
devLogger.info("[nostr] Fetching watch history from relays.", {
actor: resolvedActor,
forceRefresh: options.forceRefresh === true,
});
const existingEntry = this.cache.get(actorKey);
const now = Date.now();
const ttl = this.getCacheTtlMs();
const loadFromStorage = async () => {
const storageEntry = this.getStorage().actors?.[actorKey];
let records = storageEntry?.records || {};
let items = storageEntry?.items || [];
if (!Object.keys(records).length && items.length) {
records = canonicalizeWatchHistoryItems(items, WATCH_HISTORY_MAX_ITEMS);
items = Object.values(records).flat();
} else if (Object.keys(records).length && !items.length) {
items = Object.values(records).flat();
}
const fingerprint = typeof storageEntry?.fingerprint === "string"
? storageEntry.fingerprint
: await this.getFingerprint(resolvedActor, records);
const entry = {
actor: resolvedActor,
items,
records,
snapshotId: typeof storageEntry?.snapshotId === "string"
? storageEntry.snapshotId
: "",
pointerEvent: null,
savedAt: now,
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
return { pointerEvent: null, items, snapshotId: entry.snapshotId };
};
if (!actorKeyIsHex) {
devLogger.warn(
`[nostr] Cannot normalize watch history actor key to hex. Aborting relay fetch for ${resolvedActor}.`,
);
if (
!options.forceRefresh &&
existingEntry &&
Number.isFinite(existingEntry.savedAt) &&
now - existingEntry.savedAt < ttl
) {
return {
pointerEvent: existingEntry.pointerEvent || null,
items: existingEntry.items || [],
snapshotId: existingEntry.snapshotId || "",
};
}
return await loadFromStorage();
}
if (
!options.forceRefresh &&
existingEntry &&
Number.isFinite(existingEntry.savedAt) &&
now - existingEntry.savedAt < ttl
) {
devLogger.info("[nostr] Using cached watch history entry.", {
actor: resolvedActor,
itemCount: Array.isArray(existingEntry.items) ? existingEntry.items.length : 0,
cacheAgeMs: now - existingEntry.savedAt,
});
return {
pointerEvent: existingEntry.pointerEvent || null,
items: existingEntry.items || [],
snapshotId: existingEntry.snapshotId || "",
};
}
const pool = typeof this.deps.getPool === "function" ? this.deps.getPool() : null;
if (!pool) {
devLogger.warn(
"[nostr] Cannot fetch watch history because relay pool is unavailable. Returning cached values.",
);
return {
pointerEvent: existingEntry?.pointerEvent || null,
items: existingEntry?.items || [],
snapshotId: existingEntry?.snapshotId || "",
};
}
const limitRaw = Number(WATCH_HISTORY_FETCH_EVENT_LIMIT);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50;
let readRelays = [];
if (typeof this.deps.getReadRelays === "function") {
const read = this.deps.getReadRelays();
if (Array.isArray(read) && read.length) {
readRelays = [...read];
}
}
if (!readRelays.length && typeof this.deps.getRelayFallback === "function") {
const fallback = this.deps.getRelayFallback();
if (Array.isArray(fallback) && fallback.length) {
readRelays = [...fallback];
}
}
if (!readRelays.length) {
readRelays = Array.from(RELAY_URLS);
}
let pointerEvents = [];
try {
const kinds = [WATCH_HISTORY_KIND];
const filters = [
{
kinds,
authors: [actorKey],
// Filter by the new version tag to ignore older history lists
"#v": [WATCH_HISTORY_VERSION_TAG_VALUE],
limit,
},
];
const results = await pool.list(readRelays, filters);
pointerEvents = Array.isArray(results)
? results
.flat()
.filter((event) => event && typeof event === "object")
: [];
} catch (error) {
devLogger.warn("[nostr] Failed to fetch watch history pointer:", error);
}
// Process all events
let eventToProcess = pointerEvents.length > 0 ? pointerEvents : null;
if (!eventToProcess || eventToProcess.length === 0) {
devLogger.info(
"[nostr] No watch history event found on relays. Falling back to storage.",
{
actor: resolvedActor,
},
);
return await loadFromStorage();
}
// Determine snapshotId from the latest event (prioritizing monthly)
const sortedEvents = [...eventToProcess].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const latestEvent = sortedEvents[0];
const chunkIdentifiers = [];
for (const event of pointerEvents) {
const tags = Array.isArray(event?.tags) ? event.tags : [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] !== "a" || typeof tag[1] !== "string") {
continue;
}
const segments = tag[1].split(":");
if (segments.length < 3) {
continue;
}
const kind = segments[0];
const pubkey = segments[1];
const identifier = segments.slice(2).join(":");
if (String(WATCH_HISTORY_KIND) !== kind) {
continue;
}
if (normalizeActorKey(pubkey) !== actorKey) {
continue;
}
if (identifier && !chunkIdentifiers.includes(identifier)) {
chunkIdentifiers.push(identifier);
}
}
}
let decryptedItems = [];
if (chunkIdentifiers.length && decryptSigner) {
try {
const results = await pool.list(readRelays, [
{
kinds: [WATCH_HISTORY_KIND],
authors: [actorKey],
"#d": chunkIdentifiers,
},
]);
const chunkEvents = Array.isArray(results)
? results.flat().filter((event) => event && typeof event === "object")
: [];
const decryptionPromises = chunkEvents.map(async (event) => {
const ciphertext = typeof event.content === "string" ? event.content : "";
if (!ciphertext) {
return [];
}
let plaintext = "";
if (typeof decryptSigner.nip04Decrypt === "function") {
try {
plaintext = await decryptSigner.nip04Decrypt(actorKey, ciphertext);
} catch (error) {
devLogger.warn("[nostr] Failed to decrypt watch history chunk:", error);
}
}
if (!plaintext) {
return [];
}
const parsed = parseWatchHistoryPayload(plaintext);
if (Array.isArray(parsed.items) && parsed.items.length) {
return parsed.items;
}
return [];
});
const chunkResults = await Promise.all(decryptionPromises);
decryptedItems.push(...chunkResults.flat());
} catch (error) {
devLogger.warn("[nostr] Failed to fetch watch history chunks:", error);
}
}
// We will collect items from all events and merge them
const eventsToProcess = Array.isArray(eventToProcess) ? eventToProcess : [eventToProcess];
const collectedItems = [];
// Deduplicate events by ID
const uniqueEvents = new Map();
for (const ev of eventsToProcess) {
if (ev && ev.id) uniqueEvents.set(ev.id, ev);
}
for (const event of uniqueEvents.values()) {
const fallbackPointers = extractPointerItemsFromEvent(event);
const ciphertext = typeof event.content === "string" ? event.content : "";
const dTag = event.tags.find(t => t[0] === 'd');
const eventSnapshotId = dTag ? dTag[1] : "";
// Try parsing as plaintext first
const payload = parseWatchHistoryContentWithFallback(
ciphertext,
fallbackPointers,
{
version: 0,
items: fallbackPointers,
snapshot: eventSnapshotId,
}
);
if (Array.isArray(payload?.items)) {
collectedItems.push(...payload.items);
}
}
const fallbackItems = extractPointerItemsFromEvent(latestEvent);
const mergedItems = decryptedItems.length
? decryptedItems
: collectedItems.length
? collectedItems
: fallbackItems;
// Canonicalize into buckets
const records = canonicalizeWatchHistoryItems(
mergedItems,
WATCH_HISTORY_MAX_ITEMS,
);
const flatItems = Object.keys(records)
.sort()
.reverse()
.map((k) => records[k])
.flat();
const fingerprint = await this.getFingerprint(
resolvedActor,
records,
);
const entry = {
actor: resolvedActor,
items: flatItems,
records,
snapshotId: "", // No single snapshot ID anymore
pointerEvent: latestEvent,
savedAt: now,
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
return { pointerEvent: latestEvent, items: flatItems, snapshotId: "" };
}
async resolve(actorInput, options = {}) {
const actorCandidates = [actorInput];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
}
let resolvedActor = "";
for (const candidate of actorCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
resolvedActor = candidate.trim();
break;
}
}
if (!resolvedActor && typeof this.deps.ensureSessionActor === "function") {
resolvedActor = await this.deps.ensureSessionActor();
}
const actorKey = normalizeActorKey(resolvedActor);
if (!actorKey) {
return [];
}
devLogger.info("[nostr] Resolving watch history for actor.", {
actor: resolvedActor,
forceRefresh: options.forceRefresh === true,
});
const storage = this.getStorage();
const storageEntry = storage.actors?.[actorKey];
let fallbackItems = [];
if (storageEntry?.records) {
fallbackItems = Object.values(storageEntry.records).flat();
} else if (storageEntry?.items) {
fallbackItems = storageEntry.items;
}
const fetchResult = await this.fetch(resolvedActor, {
forceRefresh: options.forceRefresh || false,
});
const merged = mergeWatchHistoryItemsWithFallback(
{
version: 2,
items: fetchResult.items || [],
snapshot: fetchResult.snapshotId || "",
},
fallbackItems,
);
const shouldBatch = Boolean(WATCH_HISTORY_BATCH_RESOLVE);
const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
const hasCustomBatchSize =
Number.isFinite(batchPageSizeRaw) && batchPageSizeRaw > 0;
const batchLimit = shouldBatch && hasCustomBatchSize
? Math.min(Math.floor(batchPageSizeRaw), WATCH_HISTORY_MAX_ITEMS)
: WATCH_HISTORY_MAX_ITEMS;
// Re-bucket with limit
const records = canonicalizeWatchHistoryItems(
merged.items || [],
batchLimit,
);
const flatItems = Object.keys(records)
.sort()
.reverse()
.map((k) => records[k])
.flat();
const fingerprint = await this.getFingerprint(
resolvedActor,
records,
);
devLogger.info("[nostr] Watch history fetch complete.", {
actor: resolvedActor,
snapshotId: fetchResult.snapshotId || null,
pointerFound: !!fetchResult.pointerEvent,
itemCount: flatItems.length,
});
devLogger.info("[nostr] Watch history resolved and cached.", {
actor: resolvedActor,
itemCount: flatItems.length,
snapshotId: fetchResult.snapshotId || null,
});
const entry = {
actor: resolvedActor,
records,
items: flatItems,
snapshotId: fetchResult.snapshotId || storage.actors?.[actorKey]?.snapshotId || "",
pointerEvent: fetchResult.pointerEvent || null,
savedAt: Date.now(),
fingerprint,
metadata: {},
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
return flatItems;
}
clear() {
for (const timer of this.republishTimers.values()) {
if (timer && typeof timer.timer === "number") {
clearTimeout(timer.timer);
} else if (timer && timer.timer) {
clearTimeout(timer.timer);
} else if (typeof timer === "number") {
clearTimeout(timer);
}
}
this.republishTimers.clear();
this.cache.clear();
this.fingerprints.clear();
this.refreshPromises.clear();
this.lastCreatedAt = 0;
this.storage = null;
}
}
export function getWatchHistoryCacheTtlMs(manager) {
return manager.getCacheTtlMs();
}
export function getWatchHistoryStorage(manager) {
return manager.getStorage();
}
export function persistWatchHistoryEntry(manager, actorInput, entry) {
return manager.persistEntry(actorInput, entry);
}
export function cancelWatchHistoryRepublish(manager, taskId = null) {
return manager.cancelRepublish(taskId);
}
export function scheduleWatchHistoryRepublish(
manager,
taskId,
operation,
options = {},
) {
return manager.scheduleRepublish(taskId, operation, options);
}
export function getWatchHistoryFingerprint(
manager,
actorInput,
itemsOverride = null,
) {
return manager.getFingerprint(actorInput, itemsOverride);
}
export function ensureWatchHistoryBackgroundRefresh(manager, actorInput = null) {
return manager.ensureBackgroundRefresh(actorInput);
}
export function publishWatchHistorySnapshot(manager, rawItems, options = {}) {
return manager.publishSnapshot(rawItems, options);
}
export function updateWatchHistoryList(manager, rawItems = [], options = {}) {
return manager.updateList(rawItems, options);
}
export function removeWatchHistoryItem(manager, pointerInput, options = {}) {
return manager.removeItem(pointerInput, options);
}
export function fetchWatchHistory(manager, actorInput, options = {}) {
return manager.fetch(actorInput, options);
}
export function resolveWatchHistory(manager, actorInput, options = {}) {
return manager.resolve(actorInput, options);
}
/**
* Create a watch-history manager. Callers supply the nostr client hooks needed
* to sign, encrypt, fetch, and publish events. All dependencies are optional;
* the manager feature-detects and no-ops when a capability is unavailable.
*
* @param {object} deps - Lazy dependency bag wired up by NostrClient.
* @returns {WatchHistoryManager}
*/
export function createWatchHistoryManager(deps) {
return new WatchHistoryManager(deps);
}
/**
* Convenience bundle for consumers that only need helper utilities without a
* full manager instance.
*/
export const watchHistoryHelpers = {
canonicalizeWatchHistoryItems,
sanitizeWatchHistoryMetadata,
serializeWatchHistoryItems,
computeWatchHistoryFingerprintForItems,
extractPointerItemsFromEvent,
};