From 65198c3abe77273a6e2719844c5443cb600d287d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:10:05 -0400 Subject: [PATCH 01/82] Add view counter configuration knobs --- config/instance-config.js | 31 +++++++++++++++++++++++++++++++ docs/nostr-analytics.md | 9 +++++++++ js/config.js | 6 ++++++ 3 files changed, 46 insertions(+) create mode 100644 docs/nostr-analytics.md diff --git a/config/instance-config.js b/config/instance-config.js index 4c45d063..8ae587ac 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -113,3 +113,34 @@ export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; * retention guarantees. */ export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * How long the view counter should treat repeat plays as duplicates. + * + * BitVid de-duplicates view events that occur within this rolling window so + * quick refreshes or stalled replays do not inflate the totals. The default of + * 24 hours mirrors common analytics tooling, but you can tighten or relax the + * window depending on how aggressively you want to filter repeat traffic. + */ +export const VIEW_COUNT_DEDUPE_WINDOW_SECONDS = 86_400; + +/** + * How far back the view counter should hydrate historical events during + * backfills. + * + * When a new analytics worker starts up, it can walk older Nostr events to + * reconstruct totals. Limiting the backfill horizon keeps catch-up jobs + * bounded—90 days covers recent trends without hammering relays for year-old + * history. Increase the number if you need deeper analytics or shrink it for + * lighter start-up workloads. + */ +export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90; + +/** + * How long clients can trust cached view totals before re-fetching. + * + * Cached results smooth out traffic spikes and reduce relay load. Five minutes + * strikes a balance between responsiveness and efficiency; lower the TTL for + * fresher dashboards or raise it if your analytics traffic is heavy. + */ +export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000; diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md new file mode 100644 index 00000000..87e51465 --- /dev/null +++ b/docs/nostr-analytics.md @@ -0,0 +1,9 @@ +# Nostr Analytics Knobs + +BitVid's view counter emits Nostr events so operators can track engagement without duplicating storage. Tune the following exports in `config/instance-config.js` to match your retention and performance goals: + +- `VIEW_COUNT_DEDUPE_WINDOW_SECONDS` (default: `86_400`): repeat plays from the same viewer inside this window are treated as duplicates so stalled reloads do not inflate totals. Shorten the window to count more aggressive replays, or extend it if you want conservative numbers. +- `VIEW_COUNT_BACKFILL_MAX_DAYS` (default: `90`): controls how far back hydrators should walk history when a new analytics worker boots. Longer windows deliver deeper trend lines at the cost of heavier relay scans. +- `VIEW_COUNT_CACHE_TTL_MS` (default: `5 * 60 * 1000`): defines how long cached aggregates remain trustworthy before clients refresh them. Lower values surface spikes faster, while higher ones smooth traffic for relay-friendly dashboards. + +Most operators can ship with the defaults—24-hour deduplication, a 90-day backfill horizon, and five-minute cache TTLs match what we run in production. Deviate only if your relays have unusual load constraints or your reporting needs stricter fidelity. diff --git a/js/config.js b/js/config.js index 705f48f7..a44c3cd9 100644 --- a/js/config.js +++ b/js/config.js @@ -11,6 +11,9 @@ import { 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, + VIEW_COUNT_CACHE_TTL_MS, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -61,3 +64,6 @@ export { WATCH_HISTORY_BATCH_RESOLVE }; export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; export { WATCH_HISTORY_CACHE_TTL_MS }; +export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS }; +export { VIEW_COUNT_BACKFILL_MAX_DAYS }; +export { VIEW_COUNT_CACHE_TTL_MS }; From d3d1e0c2e78607229863779ae24e7fc6d2dea803 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:20:59 -0400 Subject: [PATCH 02/82] Add helpers for video view events --- js/nostr.js | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) diff --git a/js/nostr.js b/js/nostr.js index a12fc112..e86ffc65 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -278,6 +278,68 @@ function normalizePointerInput(pointer) { return { type, value: trimmed, relay: null }; } +function resolveVideoViewPointerValue(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."); + } + + return value; +} + +function createVideoViewEventFilter(pointer) { + const pointerValue = resolveVideoViewPointerValue(pointer); + return { + kinds: [WATCH_HISTORY_KIND], + "#t": ["view"], + "#video": [pointerValue], + }; +} + +function isVideoViewEvent(event, pointerValue) { + if (!event || typeof event !== "object") { + return false; + } + + if (Number(event.kind) !== WATCH_HISTORY_KIND) { + return false; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let hasViewTag = false; + let hasVideoTag = false; + + 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; + } else if (!hasVideoTag && label === "video" && value === pointerValue) { + hasVideoTag = true; + } + + if (hasViewTag && hasVideoTag) { + return true; + } + } + + return false; +} + function extractPointerItemsFromEvent(event) { if (!event || typeof event !== "object") { return []; @@ -2143,6 +2205,265 @@ class NostrClient { return { ok: success, event: signedEvent, results: publishResults }; } + async listVideoViewEvents(pointer, options = {}) { + if (!this.pool) { + return []; + } + + const filter = createVideoViewEventFilter(pointer); + const pointerValue = filter["#video"][0]; + const { since, until, limit, relays } = options || {}; + + 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, [filter]); + } 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, pointerValue)) { + 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 filter = createVideoViewEventFilter(pointer); + const pointerValue = filter["#video"][0]; + + if (Number.isFinite(options?.since)) { + 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, [filter]); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to open video view subscription:", error); + } + return () => {}; + } + + if (onEvent) { + subscription.on("event", (event) => { + if (isVideoViewEvent(event, pointerValue)) { + 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 = {}) { + if (!this.pool) { + const events = await this.listVideoViewEvents(pointer, options); + return { + total: Array.isArray(events) ? events.length : 0, + perRelay: [], + fallback: true, + }; + } + + const filter = createVideoViewEventFilter(pointer); + const relayList = Array.isArray(options?.relays) && options.relays.length + ? options.relays + : undefined; + + 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([filter], { + 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, { + relays: relayList, + }); + + const events = abortPromise + ? await Promise.race([listPromise, abortPromise]) + : await listPromise; + + return { + total: Array.isArray(events) ? events.length : 0, + perRelay: [], + fallback: true, + }; + } + async updateWatchHistoryList(pointer) { if (!this.pool) { return { ok: false, error: "nostr-uninitialized" }; @@ -4549,3 +4870,24 @@ export const 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); +}; From 0de482efc010f3015b72d13f3449343f4843460e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:25:39 -0400 Subject: [PATCH 03/82] Add client-side view counter module --- js/viewCounter.js | 635 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 js/viewCounter.js diff --git a/js/viewCounter.js b/js/viewCounter.js new file mode 100644 index 00000000..cd38164c --- /dev/null +++ b/js/viewCounter.js @@ -0,0 +1,635 @@ +/** + * @module viewCounter + * @description Public API for tracking and formatting BitVid video view totals. + * + * The module exposes lifecycle helpers: + * - {@link initViewCounter} to prime the counter with the active Nostr client. + * - {@link subscribeToVideoViewCount} / {@link unsubscribeFromVideoViewCount} + * to observe live totals for an `a` or `e` pointer. + * - {@link ingestLocalViewEvent} so playback flows can optimistically update + * counts immediately after publishing a view event. + * - {@link formatViewCount} for rendering localized view totals in the UI. + */ + +import { + VIEW_COUNT_BACKFILL_MAX_DAYS, + VIEW_COUNT_CACHE_TTL_MS, + VIEW_COUNT_DEDUPE_WINDOW_SECONDS, +} from "./config.js"; +import { + countVideoViewEvents, + listVideoViewEvents, + subscribeVideoViewEvents, +} from "./nostr.js"; + +const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1"; +const STORAGE_DEBOUNCE_MS = 500; +const SECONDS_PER_DAY = 86_400; + +/** @type {Map} */ +const pointerStates = new Map(); +/** @type {Map} */ +const pointerListeners = new Map(); +let persistTimer = null; +let nextTokenId = 1; +let nostrClientRef = null; + +restoreCacheSnapshot(); + +/** + * @typedef {Object} ViewCounterState + * @property {number} total + * @property {Map} dedupeBuckets + * @property {number} lastSyncedAt + * @property {"idle"|"hydrating"|"live"} status + */ + +/** + * @typedef {Object} PointerListeners + * @property {{ type: string, value: string, relay?: string | null }} pointer + * @property {Map} handlers + * @property {(() => void) | null} liveUnsub + * @property {Promise | null} hydrationPromise + * @property {Object | null} options + */ + +/** + * Persist the in-memory cache to localStorage after a short debounce. + */ +function schedulePersist() { + if (!supportsStorage()) { + return; + } + if (persistTimer) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(() => { + persistTimer = null; + persistCacheSnapshot(); + }, STORAGE_DEBOUNCE_MS); +} + +/** + * Convert a raw pointer input into the canonical key and normalized descriptor. + * + * @param {string | { type?: string, value?: string, relay?: string | null } | { tag?: any }} input + * @returns {{ key: string, pointer: { type: string, value: string, relay?: string | null } }} + */ +function canonicalizePointer(input) { + const normalized = normalizePointer(input); + if (!normalized) { + throw new Error("Invalid pointer supplied for view counting."); + } + + const type = normalized.type === "a" ? "a" : "e"; + const value = typeof normalized.value === "string" ? normalized.value.trim() : ""; + if (!value) { + throw new Error("Invalid pointer supplied for view counting."); + } + + const key = `${type}:${value}`; + const descriptor = { type, value }; + if (normalized.relay) { + descriptor.relay = normalized.relay; + } + return { key, pointer: descriptor }; +} + +/** + * Mirror the pointer normalization logic used inside the Nostr client. + * + * @param {any} pointer + * @returns {{ type: string, value: string, relay?: string | null } | null} + */ +function normalizePointer(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 clonePointer(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")) { + const decoder = typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null; + if (typeof decoder === "function") { + try { + 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 (error) { + console.warn("[viewCounter] Failed to decode NIP-19 pointer:", error); + } + } + } + + const type = trimmed.includes(":") ? "a" : "e"; + return { type, value: trimmed, relay: null }; +} + +function clonePointer(pointer) { + 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 : null; + return relay ? { type, value, relay } : { type, value }; +} + +function normalizePointerTag(tag) { + if (!Array.isArray(tag) || tag.length < 2) { + return null; + } + const [label, value, relay] = tag; + if (label === "a" && typeof value === "string") { + const normalized = value.trim(); + if (!normalized) { + return null; + } + const relayValue = typeof relay === "string" ? relay : null; + return relayValue ? { type: "a", value: normalized, relay: relayValue } : { type: "a", value: normalized }; + } + if (label === "e" && typeof value === "string") { + const normalized = value.trim(); + if (!normalized) { + return null; + } + const relayValue = typeof relay === "string" ? relay : null; + return relayValue ? { type: "e", value: normalized, relay: relayValue } : { type: "e", value: normalized }; + } + return null; +} + +function supportsStorage() { + try { + return typeof localStorage !== "undefined"; + } catch (error) { + return false; + } +} + +function restoreCacheSnapshot() { + if (!supportsStorage()) { + return; + } + try { + const raw = localStorage.getItem(VIEW_COUNTER_STORAGE_KEY); + if (!raw) { + return; + } + const payload = JSON.parse(raw); + if (!payload || payload.version !== 1) { + return; + } + const savedAt = Number(payload.savedAt) || 0; + const now = Date.now(); + if (!savedAt || now - savedAt > VIEW_COUNT_CACHE_TTL_MS) { + localStorage.removeItem(VIEW_COUNTER_STORAGE_KEY); + return; + } + const entries = Array.isArray(payload.entries) ? payload.entries : []; + for (const entry of entries) { + if (!Array.isArray(entry) || entry.length !== 2) { + continue; + } + const [key, state] = entry; + if (typeof key !== "string" || !state || typeof state !== "object") { + continue; + } + const total = Number.isFinite(state.total) ? Number(state.total) : 0; + const lastSyncedAt = Number.isFinite(state.lastSyncedAt) ? Number(state.lastSyncedAt) : 0; + const status = typeof state.status === "string" ? state.status : "idle"; + const dedupeBuckets = new Map(); + if (Array.isArray(state.dedupeBuckets)) { + for (const bucket of state.dedupeBuckets) { + if (!Array.isArray(bucket) || bucket.length < 1) { + continue; + } + const [bucketKey, bucketValue] = bucket; + if (typeof bucketKey === "string") { + dedupeBuckets.set( + bucketKey, + Number.isFinite(bucketValue) ? Number(bucketValue) : 0 + ); + } + } + } else if (state.dedupeBuckets && typeof state.dedupeBuckets === "object") { + for (const bucketKey of Object.keys(state.dedupeBuckets)) { + const bucketValue = state.dedupeBuckets[bucketKey]; + if (typeof bucketKey === "string") { + dedupeBuckets.set( + bucketKey, + Number.isFinite(bucketValue) ? Number(bucketValue) : 0 + ); + } + } + } + pointerStates.set(key, { + total, + dedupeBuckets, + lastSyncedAt, + status: status === "hydrating" || status === "live" ? "idle" : status, + }); + } + } catch (error) { + console.warn("[viewCounter] Failed to restore cached view counts:", error); + } +} + +function persistCacheSnapshot() { + if (!supportsStorage()) { + return; + } + try { + const entries = []; + for (const [key, state] of pointerStates.entries()) { + entries.push([ + key, + { + total: state.total, + dedupeBuckets: Array.from(state.dedupeBuckets.entries()), + lastSyncedAt: state.lastSyncedAt, + status: state.status, + }, + ]); + } + const payload = { + version: 1, + savedAt: Date.now(), + entries, + }; + localStorage.setItem(VIEW_COUNTER_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + console.warn("[viewCounter] Failed to persist view count cache:", error); + } +} + +function ensurePointerState(key) { + let state = pointerStates.get(key); + if (!state) { + state = { + total: 0, + dedupeBuckets: new Map(), + lastSyncedAt: 0, + status: "idle", + }; + pointerStates.set(key, state); + } else if (!(state.dedupeBuckets instanceof Map)) { + state.dedupeBuckets = new Map( + state.dedupeBuckets ? Object.entries(state.dedupeBuckets) : [] + ); + } + return state; +} + +function mergePointerDescriptor(existing, next) { + if (!existing) { + return next; + } + if (!next) { + return existing; + } + if (!existing.relay && next.relay) { + return { ...existing, relay: next.relay }; + } + return existing; +} + +function ensurePointerListeners(key, pointer) { + let listeners = pointerListeners.get(key); + if (!listeners) { + listeners = { + pointer, + handlers: new Map(), + liveUnsub: null, + hydrationPromise: null, + options: null, + }; + pointerListeners.set(key, listeners); + } else if (!listeners.pointer) { + listeners.pointer = pointer; + } else { + listeners.pointer = mergePointerDescriptor(listeners.pointer, pointer); + } + return listeners; +} + +function notifyHandlers(key) { + const listeners = pointerListeners.get(key); + if (!listeners || !listeners.handlers.size) { + return; + } + const state = ensurePointerState(key); + const snapshot = { + total: state.total, + lastSyncedAt: state.lastSyncedAt, + status: state.status, + }; + for (const handler of listeners.handlers.values()) { + try { + handler(snapshot); + } catch (error) { + console.warn("[viewCounter] View handler threw:", error); + } + } +} + +function setPointerStatus(key, status) { + const state = ensurePointerState(key); + if (state.status === status) { + return; + } + state.status = status; + schedulePersist(); + notifyHandlers(key); +} + +function applyEventToState(key, event) { + if (!event || typeof event !== "object") { + return false; + } + const state = ensurePointerState(key); + const bucketKey = deriveBucketKey(event); + const finalKey = bucketKey || deriveFallbackKey(event); + if (!finalKey) { + return false; + } + const seenBefore = state.dedupeBuckets.has(finalKey); + const createdAt = Number.isFinite(event.created_at) + ? Number(event.created_at) + : Math.floor(Date.now() / 1000); + state.dedupeBuckets.set(finalKey, createdAt); + if (seenBefore) { + return false; + } + state.total += 1; + state.lastSyncedAt = Date.now(); + return true; +} + +function deriveBucketKey(event) { + const pubkey = typeof event?.pubkey === "string" ? event.pubkey : null; + if (!pubkey) { + return null; + } + const createdAt = Number.isFinite(event?.created_at) + ? Number(event.created_at) + : Math.floor(Date.now() / 1000); + const windowSize = Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS); + const bucket = Math.floor(createdAt / windowSize); + return `${pubkey}:${bucket}`; +} + +function deriveFallbackKey(event) { + if (typeof event?.id === "string" && event.id) { + return `event:${event.id}`; + } + if (typeof event?.sig === "string" && event.sig) { + return `sig:${event.sig}`; + } + return null; +} + +function mergeListenerOptions(existing, next) { + if (!existing) { + return next || null; + } + if (!next) { + return existing; + } + return { + ...existing, + ...next, + }; +} + +async function hydratePointer(key, listeners) { + const pointer = listeners.pointer; + const options = listeners.options || {}; + setPointerStatus(key, "hydrating"); + const sinceSeconds = Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY; + const listPromise = listVideoViewEvents(pointer, { + since: sinceSeconds, + relays: options.relays, + }); + const countPromise = countVideoViewEvents(pointer, { + relays: options.relays, + signal: options.signal, + }); + let listResult = []; + let countResult = null; + try { + listResult = await listPromise; + } catch (error) { + console.warn("[viewCounter] Failed to hydrate view events via list:", error); + } + try { + countResult = await countPromise; + } catch (error) { + if (error?.name === "AbortError") { + console.warn("[viewCounter] View count hydration aborted:", error); + } else { + console.warn("[viewCounter] Failed to count view events:", error); + } + } + let mutated = false; + if (Array.isArray(listResult)) { + for (const event of listResult) { + mutated = applyEventToState(key, event) || mutated; + } + } + if (countResult && Number.isFinite(countResult.total)) { + const state = ensurePointerState(key); + if (countResult.total > state.total) { + state.total = Number(countResult.total); + state.lastSyncedAt = Date.now(); + mutated = true; + } + } + if (mutated) { + schedulePersist(); + notifyHandlers(key); + } else { + // Even if nothing changed we still update last synced timestamp. + const state = ensurePointerState(key); + state.lastSyncedAt = Date.now(); + schedulePersist(); + notifyHandlers(key); + } +} + +function ensureHydration(key, listeners) { + if (!listeners.hydrationPromise) { + listeners.hydrationPromise = hydratePointer(key, listeners).finally(() => { + listeners.hydrationPromise = null; + if (listeners.liveUnsub) { + setPointerStatus(key, "live"); + } else { + setPointerStatus(key, "idle"); + } + }); + } +} + +function ensureLiveSubscription(key, listeners) { + if (listeners.liveUnsub || !listeners.handlers.size) { + return; + } + const pointer = listeners.pointer; + const options = listeners.options || {}; + try { + const since = Math.floor(Date.now() / 1000) - Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS); + const unsubscribe = subscribeVideoViewEvents(pointer, { + relays: options.relays, + since, + onEvent: (event) => { + const changed = applyEventToState(key, event); + if (changed) { + schedulePersist(); + notifyHandlers(key); + } + }, + }); + listeners.liveUnsub = () => { + try { + unsubscribe(); + } catch (error) { + console.warn("[viewCounter] Failed to unsubscribe from view events:", error); + } + listeners.liveUnsub = null; + if (!listeners.hydrationPromise) { + setPointerStatus(key, "idle"); + } + }; + setPointerStatus(key, "live"); + } catch (error) { + console.warn("[viewCounter] Failed to open live view subscription:", error); + } +} + +export function initViewCounter({ nostrClient } = {}) { + nostrClientRef = nostrClient || null; +} + +export function subscribeToVideoViewCount(pointerInput, handler, options = {}) { + if (typeof handler !== "function") { + throw new Error("A handler function is required to subscribe to view counts."); + } + const { key, pointer } = canonicalizePointer(pointerInput); + const listeners = ensurePointerListeners(key, pointer); + listeners.options = mergeListenerOptions(listeners.options, options); + const token = `token-${nextTokenId++}`; + listeners.handlers.set(token, handler); + const state = ensurePointerState(key); + try { + handler({ + total: state.total, + lastSyncedAt: state.lastSyncedAt, + status: state.status, + }); + } catch (error) { + console.warn("[viewCounter] Initial handler invocation threw:", error); + } + ensureHydration(key, listeners); + ensureLiveSubscription(key, listeners); + return token; +} + +export function unsubscribeFromVideoViewCount(pointerInput, token) { + if (!token) { + return; + } + const { key } = canonicalizePointer(pointerInput); + const listeners = pointerListeners.get(key); + if (!listeners) { + return; + } + listeners.handlers.delete(token); + if (!listeners.handlers.size && listeners.liveUnsub) { + listeners.liveUnsub(); + } +} + +export function ingestLocalViewEvent({ event, pointer }) { + if (!event || !pointer) { + return; + } + try { + const { key } = canonicalizePointer(pointer); + const changed = applyEventToState(key, event); + if (changed) { + schedulePersist(); + notifyHandlers(key); + } + } catch (error) { + console.warn("[viewCounter] Failed to ingest local view event:", error); + } +} + +const numberFormatter = typeof Intl !== "undefined" && Intl.NumberFormat + ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }) + : null; +const compactFormatter = typeof Intl !== "undefined" && Intl.NumberFormat + ? new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }) + : null; + +export function formatViewCount(total) { + const value = Number.isFinite(total) ? Number(total) : 0; + if (value < 1000) { + if (numberFormatter) { + return numberFormatter.format(value); + } + return String(value); + } + if (compactFormatter) { + return compactFormatter.format(value); + } + return String(value); +} From 5a243eea50ea813e4892e50876822482d022d104 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:40:04 -0400 Subject: [PATCH 04/82] Add live view counters to video list and modal --- components/video-modal.html | 5 +- css/style.css | 21 +++ js/app.js | 317 +++++++++++++++++++++++++++++++++++- 3 files changed, 340 insertions(+), 3 deletions(-) diff --git a/components/video-modal.html b/components/video-modal.html index c66f96e6..5b63b7c3 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -133,7 +133,10 @@
- just now +
Initializing... Just give it a sec.
diff --git a/css/style.css b/css/style.css index 65cdbe6c..3f61eb93 100644 --- a/css/style.css +++ b/css/style.css @@ -885,6 +885,27 @@ header img { box-shadow: inset 0 1px 0 rgba(15, 23, 42, 0.35); } +.modal-meta { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.modal-view-count { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + color: #9ca3af; + white-space: nowrap; +} + +.view-count-text { + white-space: nowrap; + color: #6b7280; +} + html.modal-open { overflow: hidden; } diff --git a/js/app.js b/js/app.js index 55af3fef..49ad2aaa 100644 --- a/js/app.js +++ b/js/app.js @@ -42,6 +42,13 @@ import { } from "./storage/r2-s3.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; +import { + initViewCounter, + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + formatViewCount, + ingestLocalViewEvent, +} from "./viewCounter.js"; function truncateMiddle(text, maxLength = 72) { if (!text || typeof text !== "string") { @@ -716,6 +723,9 @@ class bitvidApp { this.videoList = null; this._videoListElement = null; this._videoListClickHandler = null; + this.viewCountSubscriptions = new Map(); + this.modalViewCountUnsub = null; + this.videoViewCountEl = null; // Videos stored as a Map (key=event.id) this.videosMap = new Map(); @@ -1241,6 +1251,12 @@ class bitvidApp { // 4. Connect to Nostr await nostrClient.init(); + try { + initViewCounter({ nostrClient }); + } catch (error) { + console.warn("Failed to initialize view counter:", error); + } + try { await accessControl.refresh(); } catch (error) { @@ -1363,6 +1379,9 @@ class bitvidApp { lastScrollY = currentScrollY; }); + this.videoViewCountEl = + document.getElementById("videoViewCount") || null; + console.log("Video modal initialization successful"); return true; } catch (error) { @@ -1386,6 +1405,8 @@ class bitvidApp { this.videoTitle = document.getElementById("videoTitle") || null; this.videoDescription = document.getElementById("videoDescription") || null; this.videoTimestamp = document.getElementById("videoTimestamp") || null; + this.videoViewCountEl = + document.getElementById("videoViewCount") || null; // The two elements we want to make clickable this.creatorAvatar = document.getElementById("creatorAvatar") || null; @@ -2649,6 +2670,228 @@ class bitvidApp { return ""; } + deriveVideoPointerInfo(video) { + if (!video || typeof video !== "object") { + return null; + } + + const dTagValue = (this.extractDTagValue(video.tags) || "").trim(); + const normalizedPubkey = + typeof video.pubkey === "string" ? video.pubkey.trim() : ""; + const kind = + typeof video.kind === "number" && Number.isFinite(video.kind) + ? video.kind + : VIDEO_EVENT_KIND; + + if (dTagValue && normalizedPubkey) { + const pointer = ["a", `${kind}:${normalizedPubkey}:${dTagValue}`]; + const key = pointerArrayToKey(pointer); + if (key) { + return { pointer, key }; + } + } + + const fallbackId = + typeof video.id === "string" && video.id.trim() + ? video.id.trim() + : ""; + if (fallbackId) { + const pointer = ["e", fallbackId]; + const key = pointerArrayToKey(pointer); + if (key) { + return { pointer, key }; + } + } + + return null; + } + + formatViewCountLabel(total) { + const value = Number.isFinite(total) ? Number(total) : 0; + const label = value === 1 ? "view" : "views"; + return `${formatViewCount(value)} ${label}`; + } + + ensureViewCountSubscription(pointerInfo) { + if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) { + return null; + } + + const existing = this.viewCountSubscriptions.get(pointerInfo.key); + if (existing) { + return existing; + } + + const entry = { + pointer: pointerInfo.pointer, + key: pointerInfo.key, + token: null, + elements: new Set(), + lastTotal: null, + lastStatus: "idle", + lastText: "– views", + }; + + try { + const token = subscribeToVideoViewCount( + pointerInfo.pointer, + ({ total, status }) => { + let text; + if (Number.isFinite(total)) { + const numeric = Number(total); + entry.lastTotal = numeric; + text = this.formatViewCountLabel(numeric); + } else if (status === "hydrating") { + text = "Loading views…"; + } else { + text = "– views"; + } + + entry.lastStatus = status; + entry.lastText = text; + + for (const el of Array.from(entry.elements)) { + if (!el || !el.isConnected) { + entry.elements.delete(el); + continue; + } + el.textContent = text; + } + } + ); + entry.token = token; + this.viewCountSubscriptions.set(pointerInfo.key, entry); + return entry; + } catch (error) { + console.warn("[viewCount] Failed to subscribe to view counter:", error); + return null; + } + } + + registerVideoViewCountElement(cardEl, pointerInfo) { + if (!cardEl || !pointerInfo) { + return; + } + + const viewCountEl = cardEl.querySelector("[data-view-count]"); + if (!viewCountEl) { + return; + } + + if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) { + viewCountEl.textContent = "– views"; + } + + const entry = this.ensureViewCountSubscription(pointerInfo); + if (!entry) { + return; + } + + entry.elements.add(viewCountEl); + if (typeof pointerInfo.key === "string") { + viewCountEl.dataset.viewPointer = pointerInfo.key; + } + viewCountEl.textContent = entry.lastText; + } + + pruneDetachedViewCountElements() { + for (const entry of this.viewCountSubscriptions.values()) { + if (!entry || !(entry.elements instanceof Set)) { + continue; + } + for (const el of Array.from(entry.elements)) { + if (!el || !el.isConnected) { + entry.elements.delete(el); + } + } + } + } + + teardownAllViewCountSubscriptions() { + const keys = Array.from(this.viewCountSubscriptions.keys()); + keys.forEach((key) => { + const entry = this.viewCountSubscriptions.get(key); + if (entry && entry.token && entry.pointer) { + try { + unsubscribeFromVideoViewCount(entry.pointer, entry.token); + } catch (error) { + console.warn( + `[viewCount] Failed to unsubscribe from pointer ${key}:`, + error + ); + } + } + this.viewCountSubscriptions.delete(key); + }); + } + + teardownModalViewCountSubscription() { + if (typeof this.modalViewCountUnsub === "function") { + try { + this.modalViewCountUnsub(); + } catch (error) { + console.warn("[viewCount] Failed to tear down modal subscription:", error); + } + } + this.modalViewCountUnsub = null; + if (this.videoViewCountEl) { + this.videoViewCountEl.textContent = "– views"; + if (this.videoViewCountEl.dataset?.viewPointer) { + delete this.videoViewCountEl.dataset.viewPointer; + } + } + } + + subscribeModalViewCount(pointer, pointerKey) { + if (!this.videoViewCountEl) { + return; + } + + this.teardownModalViewCountSubscription(); + + if (!pointer || !pointerKey) { + return; + } + + this.videoViewCountEl.textContent = "Loading views…"; + try { + const token = subscribeToVideoViewCount(pointer, ({ total, status }) => { + if (!this.videoViewCountEl) { + return; + } + + if (Number.isFinite(total)) { + const numeric = Number(total); + this.videoViewCountEl.textContent = this.formatViewCountLabel(numeric); + return; + } + + if (status === "hydrating") { + this.videoViewCountEl.textContent = "Loading views…"; + } else { + this.videoViewCountEl.textContent = "– views"; + } + }); + + this.modalViewCountUnsub = () => { + try { + unsubscribeFromVideoViewCount(pointer, token); + } catch (error) { + console.warn( + "[viewCount] Failed to unsubscribe modal view counter:", + error + ); + } finally { + this.modalViewCountUnsub = null; + } + }; + this.videoViewCountEl.dataset.viewPointer = pointerKey; + } catch (error) { + console.warn("[viewCount] Failed to subscribe modal view counter:", error); + this.videoViewCountEl.textContent = "– views"; + } + } + populateEditVideoForm(video) { if (!video || !this.editVideoModal) { return; @@ -6368,11 +6611,18 @@ class bitvidApp { this.cancelPendingViewLogging(); this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); + this.teardownModalViewCountSubscription(); if (!preserveObservers && this.mediaLoader) { this.mediaLoader.disconnect(); } + if (!preserveObservers) { + this.teardownAllViewCountSubscriptions(); + } else { + this.pruneDetachedViewCountElements(); + } + if (!preserveSubscriptions && this.videoSubscription) { try { if (typeof this.videoSubscription.unsub === "function") { @@ -6535,6 +6785,12 @@ class bitvidApp { const viewOk = !!result?.view?.ok; if (viewOk) { this.loggedViewPointerKeys.add(thresholdPointerKey); + if (result?.view?.event) { + ingestLocalViewEvent({ + event: result.view.event, + pointer: thresholdPointer, + }); + } } else if (isDevMode) { console.warn( "[playVideoWithFallback] View event rejected by relays:", @@ -7084,6 +7340,7 @@ class bitvidApp { // 1) Clear intervals, cleanup, etc. (unchanged) this.cancelPendingViewLogging(); this.clearActiveIntervals(); + this.teardownModalViewCountSubscription(); try { await fetch("/webtorrent/cancel/", { mode: "no-cors" }); @@ -7519,6 +7776,40 @@ class bitvidApp { const dedupedVideos = this.dedupeVideosByRoot(videos); + const activePointerKeys = new Set(); + dedupedVideos.forEach((video) => { + const pointerInfo = this.deriveVideoPointerInfo(video); + if (pointerInfo) { + activePointerKeys.add(pointerInfo.key); + } + }); + + const keysToRemove = []; + for (const [key, entry] of this.viewCountSubscriptions.entries()) { + if (!activePointerKeys.has(key)) { + keysToRemove.push(key); + continue; + } + if (entry && entry.elements instanceof Set) { + entry.elements.clear(); + } + } + + keysToRemove.forEach((key) => { + const entry = this.viewCountSubscriptions.get(key); + if (entry && entry.pointer && entry.token) { + try { + unsubscribeFromVideoViewCount(entry.pointer, entry.token); + } catch (error) { + console.warn( + `[viewCount] Failed to unsubscribe from stale pointer ${key}:`, + error + ); + } + } + this.viewCountSubscriptions.delete(key); + }); + if (this.loadedThumbnails && this.loadedThumbnails.size) { const activeIds = new Set(); dedupedVideos.forEach((video) => { @@ -7793,6 +8084,18 @@ class bitvidApp { `alt=\"${this.escapeHTML(video.title)}\"` ); + const pointerInfo = this.deriveVideoPointerInfo(video); + const metadataPieces = [`${timeAgo}`]; + if (pointerInfo) { + metadataPieces.push( + '', + `– views` + ); + } + const metadataHtml = metadataPieces.join(""); + const cardHtml = `
@@ -7839,8 +8142,7 @@ class bitvidApp { Loading name...

- ${timeAgo} - + ${metadataHtml}
@@ -7893,6 +8195,10 @@ class bitvidApp { : "missing-source"; } + if (pointerInfo) { + this.registerVideoViewCountElement(cardEl, pointerInfo); + } + const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]"); if (thumbnailEl) { const markThumbnailAsLoaded = () => { @@ -8152,6 +8458,7 @@ class bitvidApp { }); this.refreshVideoDiscussionCounts(dedupedVideos); + this.pruneDetachedViewCountElements(); } refreshVideoDiscussionCounts(videos = []) { @@ -9999,6 +10306,11 @@ class bitvidApp { this.currentVideo.pointerKey = this.currentVideoPointerKey; } + this.subscribeModalViewCount( + this.currentVideoPointer, + this.currentVideoPointerKey + ); + this.syncModalMoreMenuData(); this.currentMagnetUri = sanitizedMagnet || null; @@ -10079,6 +10391,7 @@ class bitvidApp { } = {}) { this.currentVideoPointer = null; this.currentVideoPointerKey = null; + this.subscribeModalViewCount(null, null); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; From 825d3c7ce4b40d56b484065a7ee3337fdbb8abd3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:51:40 -0400 Subject: [PATCH 05/82] Add view counter regression tests --- tests/view-counter.test.mjs | 362 ++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/view-counter.test.mjs diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs new file mode 100644 index 00000000..479114e2 --- /dev/null +++ b/tests/view-counter.test.mjs @@ -0,0 +1,362 @@ +// Run with: node tests/view-counter.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +const { + VIEW_COUNT_DEDUPE_WINDOW_SECONDS, + VIEW_COUNT_BACKFILL_MAX_DAYS, +} = await import("../js/config.js"); + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const { nostrClient } = await import("../js/nostr.js"); + +function createMockNostrHarness() { + const storedEvents = new Map(); + const customTotals = new Map(); + const subscribers = new Map(); + + const pointerKeyFromInput = (input) => { + if (!input) { + return ""; + } + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.includes(":")) { + return trimmed; + } + return `e:${trimmed}`; + } + if (Array.isArray(input)) { + if (input.length < 2) { + return ""; + } + const type = input[0] === "a" ? "a" : "e"; + const value = typeof input[1] === "string" ? input[1].trim() : ""; + if (!value) { + return ""; + } + return `${type}:${value}`; + } + if (typeof input === "object") { + const type = input.type === "a" ? "a" : "e"; + const value = typeof input.value === "string" ? input.value.trim() : ""; + if (!value) { + return ""; + } + return `${type}:${value}`; + } + return ""; + }; + + const listVideoViewEvents = async (pointer, options = {}) => { + const key = pointerKeyFromInput(pointer); + const events = storedEvents.get(key) || []; + const since = Number.isFinite(options?.since) ? Number(options.since) : null; + if (since === null) { + return events; + } + return events.filter((event) => { + const createdAt = Number.isFinite(event?.created_at) ? Number(event.created_at) : 0; + return createdAt >= since; + }); + }; + + const countVideoViewEvents = async (pointer) => { + const key = pointerKeyFromInput(pointer); + if (customTotals.has(key)) { + return { total: customTotals.get(key) }; + } + const events = storedEvents.get(key) || []; + return { total: events.length }; + }; + + const subscribeVideoViewEvents = (pointer, options = {}) => { + const key = pointerKeyFromInput(pointer); + let handlers = subscribers.get(key); + if (!handlers) { + handlers = new Set(); + subscribers.set(key, handlers); + } + const handler = typeof options?.onEvent === "function" ? options.onEvent : null; + if (handler) { + handlers.add(handler); + } + return () => { + if (handler && handlers) { + handlers.delete(handler); + if (!handlers.size) { + subscribers.delete(key); + } + } + }; + }; + + const emit = (pointer, event) => { + const key = pointerKeyFromInput(pointer); + const handlers = subscribers.get(key); + if (!handlers) { + return; + } + for (const handler of Array.from(handlers)) { + handler(event); + } + }; + + const setEvents = (key, events) => { + storedEvents.set(key, Array.isArray(events) ? events : []); + }; + + const setCountTotal = (key, total) => { + if (Number.isFinite(total)) { + customTotals.set(key, Number(total)); + } else { + customTotals.delete(key); + } + }; + + const reset = () => { + storedEvents.clear(); + customTotals.clear(); + subscribers.clear(); + }; + + const subscriptionCount = (key) => { + const handlers = subscribers.get(key); + return handlers ? handlers.size : 0; + }; + + return { + listVideoViewEvents, + countVideoViewEvents, + subscribeVideoViewEvents, + emit, + setEvents, + setCountTotal, + reset, + pointerKeyFromInput, + subscriptionCount, + }; +} + +const harness = createMockNostrHarness(); + +nostrClient.listVideoViewEvents = harness.listVideoViewEvents; +nostrClient.countVideoViewEvents = harness.countVideoViewEvents; +nostrClient.subscribeVideoViewEvents = harness.subscribeVideoViewEvents; + +const { + initViewCounter, + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + ingestLocalViewEvent, +} = await import("../js/viewCounter.js"); + +initViewCounter({ nostrClient }); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function testDedupesWithinWindow() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-dedupe" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const base = Math.floor(Date.now() / 1000); + const events = [ + { id: "evt-1", pubkey: "pub-dedupe", created_at: base }, + { + id: "evt-2", + pubkey: "pub-dedupe", + created_at: base + Math.floor(VIEW_COUNT_DEDUPE_WINDOW_SECONDS / 2), + }, + ]; + harness.setEvents(pointerKey, events); + harness.setCountTotal(pointerKey, 1); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected at least one state update"); + assert.equal( + final.total, + 1, + "multiple events from the same pubkey within the dedupe window should count once" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testHydrationSkipsStaleEventsAndRollsOff() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "a", value: "30078:pk:test-hydrate" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const nowSeconds = Math.floor(Date.now() / 1000); + const horizonSeconds = VIEW_COUNT_BACKFILL_MAX_DAYS * 86_400; + + const staleEvent = { + id: "evt-stale", + pubkey: "pub-hydrate", + created_at: nowSeconds - horizonSeconds - 60, + }; + const rolledOffEvent = { + id: "evt-rolled-off", + pubkey: "pub-hydrate", + created_at: nowSeconds - VIEW_COUNT_DEDUPE_WINDOW_SECONDS - 10, + }; + const recentEvent = { + id: "evt-recent", + pubkey: "pub-hydrate", + created_at: nowSeconds - 5, + }; + + harness.setEvents(pointerKey, [staleEvent, rolledOffEvent, recentEvent]); + harness.setCountTotal(pointerKey, 2); + + const updates = []; + let token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected hydration state update"); + assert.equal( + final.total, + 2, + "hydration should ignore events beyond the backfill window and keep unique buckets" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } + + const futureEvent = { + id: "evt-future", + pubkey: "pub-hydrate", + created_at: nowSeconds + VIEW_COUNT_DEDUPE_WINDOW_SECONDS + 5, + }; + + harness.setEvents(pointerKey, [rolledOffEvent, recentEvent, futureEvent]); + harness.setCountTotal(pointerKey, 3); + + const secondUpdates = []; + token = subscribeToVideoViewCount(pointer, (state) => { + secondUpdates.push({ ...state }); + }); + try { + await flushPromises(); + await flushPromises(); + const final = secondUpdates.at(-1); + assert.ok(final, "expected updated hydration state"); + assert.equal( + final.total, + 3, + "events outside the dedupe window should contribute when hydrating fresh data" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testLocalIngestNotifiesImmediately() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-local" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, 0); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + const initial = updates.at(-1); + assert.ok(initial, "expected initial state update"); + assert.equal(initial.total, 0, "initial total should start at zero"); + + const localEvent = { + id: "evt-local", + pubkey: "pub-local", + created_at: Math.floor(Date.now() / 1000), + }; + + ingestLocalViewEvent({ pointer, event: localEvent }); + + const latest = updates.at(-1); + assert.equal( + latest.total, + 1, + "local ingestion should immediately notify subscribers with incremented total" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testUnsubscribeStopsCallbacks() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-unsubscribe" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, 0); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + await flushPromises(); + const updatesBeforeUnsubscribe = updates.length; + + unsubscribeFromVideoViewCount(pointer, token); + assert.equal( + harness.subscriptionCount(pointerKey), + 0, + "tearing down the final handler should close the live subscription" + ); + + harness.emit(pointerKey, { + id: "evt-ignored", + pubkey: "pub-unsubscribe", + created_at: Math.floor(Date.now() / 1000), + }); + + assert.equal( + updates.length, + updatesBeforeUnsubscribe, + "no handlers should fire once the final subscriber has been removed" + ); +} + +await testDedupesWithinWindow(); +await testHydrationSkipsStaleEventsAndRollsOff(); +await testLocalIngestNotifiesImmediately(); +await testUnsubscribeStopsCallbacks(); + +console.log("View counter tests completed successfully."); From 5d4c0d494b8abc4a6d3045f8904fbe4beefb14bc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:11:02 -0400 Subject: [PATCH 06/82] Fix per-identity view logging --- docs/qa.md | 3 ++ js/app.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/qa.md b/docs/qa.md index 79447636..b7736575 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -20,5 +20,8 @@ Run this checklist before publishing releases or merging changes that touch uplo 6. **Saved Profile Metadata Refresh** - In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars. - Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh. +7. **View Logging Per Identity** + - Play the same video until a view event is logged, then log out (or switch to a different pubkey) and repeat the playback. + - Confirm two distinct view events appear (e.g., via relay logs or UI counters) and the aggregated view count increments twice. Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes. diff --git a/js/app.js b/js/app.js index 49ad2aaa..3e5811e0 100644 --- a/js/app.js +++ b/js/app.js @@ -6457,7 +6457,18 @@ class bitvidApp { const { persistActive = true } = normalizedOptions; + const previousActivePubkey = + this.normalizeHexPubkey(this.pubkey) || + (typeof this.pubkey === "string" ? this.pubkey.trim() : ""); + const normalizedPubkey = this.normalizeHexPubkey(pubkey); + const nextActivePubkey = + normalizedPubkey || (typeof pubkey === "string" ? pubkey.trim() : ""); + + if (previousActivePubkey !== nextActivePubkey) { + this.resetViewLoggingState(); + } + if (normalizedPubkey) { this.pubkey = normalizedPubkey; } else { @@ -6526,6 +6537,7 @@ class bitvidApp { */ async logout() { nostrClient.logout(); + this.resetViewLoggingState(); this.pubkey = null; this.currentUserNpub = null; @@ -6741,6 +6753,61 @@ class bitvidApp { this.playbackTelemetryState = null; } + resetViewLoggingState() { + this.cancelPendingViewLogging(); + if (this.loggedViewPointerKeys.size > 0) { + this.loggedViewPointerKeys.clear(); + } + } + + getActiveViewIdentityKey() { + const normalizedUser = this.normalizeHexPubkey(this.pubkey); + if (normalizedUser) { + return `actor:${normalizedUser}`; + } + + const sessionActorPubkey = this.normalizeHexPubkey( + nostrClient?.sessionActor?.pubkey + ); + if (sessionActorPubkey) { + return `actor:${sessionActorPubkey}`; + } + + return "actor:anonymous"; + } + + deriveViewIdentityKeyFromEvent(event) { + if (!event || typeof event !== "object") { + return ""; + } + + const normalizedPubkey = this.normalizeHexPubkey(event.pubkey); + if (!normalizedPubkey) { + return ""; + } + + return `actor:${normalizedPubkey}`; + } + + buildViewCooldownKey(pointerKey, identityKey) { + const normalizedPointerKey = + typeof pointerKey === "string" && pointerKey.trim() + ? pointerKey.trim() + : ""; + if (!normalizedPointerKey) { + return ""; + } + + const normalizedIdentity = + typeof identityKey === "string" && identityKey.trim() + ? identityKey.trim().toLowerCase() + : ""; + + return normalizedIdentity + ? `${normalizedPointerKey}::${normalizedIdentity}` + : normalizedPointerKey; + } + preparePlaybackViewLogging(videoEl) { this.cancelPendingViewLogging(); @@ -6754,7 +6821,10 @@ class bitvidApp { return; } - if (this.loggedViewPointerKeys.has(pointerKey)) { + const viewerIdentityKey = this.getActiveViewIdentityKey(); + const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey); + + if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) { return; } @@ -6763,6 +6833,7 @@ class bitvidApp { videoEl, pointer, pointerKey, + viewerIdentityKey, handlers: [], timerId: null, fired: false, @@ -6784,7 +6855,17 @@ class bitvidApp { const result = await recordVideoView(thresholdPointer); const viewOk = !!result?.view?.ok; if (viewOk) { - this.loggedViewPointerKeys.add(thresholdPointerKey); + const eventIdentityKey = + this.deriveViewIdentityKeyFromEvent(result?.view?.event) || + state.viewerIdentityKey || + this.getActiveViewIdentityKey(); + const keyToPersist = this.buildViewCooldownKey( + thresholdPointerKey, + eventIdentityKey + ); + if (keyToPersist) { + this.loggedViewPointerKeys.add(keyToPersist); + } if (result?.view?.event) { ingestLocalViewEvent({ event: result.view.event, From e543de8bb4eecf5c81a6f624f6e58a4b3e52623b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:18:36 -0400 Subject: [PATCH 07/82] Add TTL hydration regression test for view counter --- tests/view-counter.test.mjs | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 479114e2..043cb8be 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -6,8 +6,34 @@ import assert from "node:assert/strict"; const { VIEW_COUNT_DEDUPE_WINDOW_SECONDS, VIEW_COUNT_BACKFILL_MAX_DAYS, + VIEW_COUNT_CACHE_TTL_MS, } = await import("../js/config.js"); +const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1"; +const CACHE_TTL_TEST_POINTER = { type: "e", value: "view-counter-cache-ttl" }; + +// Seed a stale cache entry so we can verify hydration ignores data older than the TTL. +const staleSavedAt = Date.now() - (VIEW_COUNT_CACHE_TTL_MS + 60_000); +localStorage.clear(); +localStorage.setItem( + VIEW_COUNTER_STORAGE_KEY, + JSON.stringify({ + version: 1, + savedAt: staleSavedAt, + entries: [ + [ + `${CACHE_TTL_TEST_POINTER.type}:${CACHE_TTL_TEST_POINTER.value}`, + { + total: 42, + dedupeBuckets: [["pub-cache-ttl:bucket", staleSavedAt]], + lastSyncedAt: staleSavedAt - 5_000, + status: "live", + }, + ], + ], + }) +); + if (typeof globalThis.window === "undefined") { globalThis.window = {}; } @@ -22,6 +48,12 @@ function createMockNostrHarness() { const storedEvents = new Map(); const customTotals = new Map(); const subscribers = new Map(); + const metrics = { list: 0, count: 0 }; + + const resetMetrics = () => { + metrics.list = 0; + metrics.count = 0; + }; const pointerKeyFromInput = (input) => { if (!input) { @@ -60,6 +92,7 @@ function createMockNostrHarness() { }; const listVideoViewEvents = async (pointer, options = {}) => { + metrics.list += 1; const key = pointerKeyFromInput(pointer); const events = storedEvents.get(key) || []; const since = Number.isFinite(options?.since) ? Number(options.since) : null; @@ -73,6 +106,7 @@ function createMockNostrHarness() { }; const countVideoViewEvents = async (pointer) => { + metrics.count += 1; const key = pointerKeyFromInput(pointer); if (customTotals.has(key)) { return { total: customTotals.get(key) }; @@ -129,6 +163,7 @@ function createMockNostrHarness() { storedEvents.clear(); customTotals.clear(); subscribers.clear(); + resetMetrics(); }; const subscriptionCount = (key) => { @@ -136,6 +171,8 @@ function createMockNostrHarness() { return handlers ? handlers.size : 0; }; + const getCallCounts = () => ({ list: metrics.list, count: metrics.count }); + return { listVideoViewEvents, countVideoViewEvents, @@ -146,6 +183,8 @@ function createMockNostrHarness() { reset, pointerKeyFromInput, subscriptionCount, + resetMetrics, + getCallCounts, }; } @@ -169,6 +208,7 @@ const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); async function testDedupesWithinWindow() { localStorage.clear(); harness.reset(); + harness.resetMetrics(); const pointer = { type: "e", value: "view-counter-dedupe" }; const pointerKey = harness.pointerKeyFromInput(pointer); @@ -206,6 +246,7 @@ async function testDedupesWithinWindow() { async function testHydrationSkipsStaleEventsAndRollsOff() { localStorage.clear(); harness.reset(); + harness.resetMetrics(); const pointer = { type: "a", value: "30078:pk:test-hydrate" }; const pointerKey = harness.pointerKeyFromInput(pointer); @@ -281,6 +322,7 @@ async function testHydrationSkipsStaleEventsAndRollsOff() { async function testLocalIngestNotifiesImmediately() { localStorage.clear(); harness.reset(); + harness.resetMetrics(); const pointer = { type: "e", value: "view-counter-local" }; const pointerKey = harness.pointerKeyFromInput(pointer); @@ -320,6 +362,7 @@ async function testLocalIngestNotifiesImmediately() { async function testUnsubscribeStopsCallbacks() { localStorage.clear(); harness.reset(); + harness.resetMetrics(); const pointer = { type: "e", value: "view-counter-unsubscribe" }; const pointerKey = harness.pointerKeyFromInput(pointer); @@ -354,6 +397,63 @@ async function testUnsubscribeStopsCallbacks() { ); } +// Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays. +async function testHydrationRefreshesAfterCacheTtl() { + harness.reset(); + harness.resetMetrics(); + + assert.equal( + localStorage.getItem(VIEW_COUNTER_STORAGE_KEY), + null, + "stale cache snapshot should be cleared when it exceeds the TTL" + ); + + const pointer = CACHE_TTL_TEST_POINTER; + const pointerKey = harness.pointerKeyFromInput(pointer); + const nowSeconds = Math.floor(Date.now() / 1000); + const events = [ + { id: "evt-ttl-1", pubkey: "pub-cache-ttl-1", created_at: nowSeconds - 15 }, + { id: "evt-ttl-2", pubkey: "pub-cache-ttl-2", created_at: nowSeconds - 5 }, + ]; + harness.setEvents(pointerKey, events); + harness.setCountTotal(pointerKey, events.length); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + await flushPromises(); + + const initial = updates.at(0); + assert.ok(initial, "expected initial state update for cache TTL test"); + assert.equal( + initial.total, + 0, + "expired cache entries should not hydrate subscribers with stale totals" + ); + + const { list: listCalls, count: countCalls } = harness.getCallCounts(); + assert.ok( + listCalls > 0 || countCalls > 0, + "hydration should fetch fresh data from relays once the cache TTL elapses" + ); + + const final = updates.at(-1); + assert.ok(final, "expected final state update for cache TTL test"); + assert.equal( + final.total, + events.length, + "totals should reflect fresh relay data after invalidating the expired cache" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +await testHydrationRefreshesAfterCacheTtl(); await testDedupesWithinWindow(); await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); From a9be08be1d105d5b5d47ba9237457f200705e713 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:21:59 -0400 Subject: [PATCH 08/82] Gate discussion badges on available counts --- js/app.js | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/js/app.js b/js/app.js index 3e5811e0..cc42b60d 100644 --- a/js/app.js +++ b/js/app.js @@ -8124,14 +8124,35 @@ class bitvidApp { : ""; const showDiscussionCount = video.enableComments !== false; - const discussionCountHtml = showDiscussionCount - ? ` -
- — + let discussionCountHtml = ""; + if (showDiscussionCount) { + let initialDiscussionCount = null; + if (typeof video.discussionCount === "number") { + initialDiscussionCount = video.discussionCount; + } else if (typeof video.discussionCount === "string") { + const trimmedCount = video.discussionCount.trim(); + if (trimmedCount) { + const parsed = Number.parseInt(trimmedCount, 10); + if (Number.isFinite(parsed)) { + initialDiscussionCount = parsed; + } + } + } + + if ( + initialDiscussionCount !== null && + Number.isFinite(initialDiscussionCount) && + initialDiscussionCount >= 0 + ) { + const safeDiscussionCount = Math.floor(initialDiscussionCount); + discussionCountHtml = ` +
+ ${safeDiscussionCount.toLocaleString()} notes
- ` - : ""; + `; + } + } const rawThumbnail = typeof video.thumbnail === "string" ? video.thumbnail.trim() : ""; From 9309ac11ea849d521d545444080a3243de4f0f8b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:53:42 -0400 Subject: [PATCH 09/82] Wait for app initialization before handling hash views --- js/app.js | 3 ++- js/index.js | 69 ++++++++++++++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/js/app.js b/js/app.js index cc42b60d..73dff5fa 100644 --- a/js/app.js +++ b/js/app.js @@ -10853,5 +10853,6 @@ function dedupeToNewestByRoot(videos) { } export const app = new bitvidApp(); -app.init(); +export const appReady = app.init(); window.app = app; +window.appReady = appReady; diff --git a/js/index.js b/js/index.js index b6ac042c..72fd1f8e 100644 --- a/js/index.js +++ b/js/index.js @@ -362,37 +362,56 @@ function handleQueryParams() { } } -function handleHashChange() { +async function waitForAppInitialization() { + const maybePromise = window.appReady; + + if (!maybePromise || typeof maybePromise.then !== "function") { + return; + } + + try { + await maybePromise; + } catch (error) { + console.warn( + "Proceeding with hash handling despite app initialization failure:", + error + ); + } +} + +async function handleHashChange() { console.log("handleHashChange called, current hash =", window.location.hash); + await waitForAppInitialization(); + const hash = window.location.hash || ""; // Use a regex that captures up to the first ampersand or end of string. // E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile" const match = hash.match(/^#view=([^&]+)/); - if (!match || !match[1]) { - // No valid "#view=..." => default to "most-recent-videos" - import("./viewManager.js").then(({ loadView, viewInitRegistry }) => { - loadView("views/most-recent-videos.html").then(() => { - const initFn = viewInitRegistry["most-recent-videos"]; - if (typeof initFn === "function") { - initFn(); - } - }); - }); - return; + try { + const { loadView, viewInitRegistry } = await import("./viewManager.js"); + + if (!match || !match[1]) { + // No valid "#view=..." => default to "most-recent-videos" + await loadView("views/most-recent-videos.html"); + const initFn = viewInitRegistry["most-recent-videos"]; + if (typeof initFn === "function") { + initFn(); + } + return; + } + + const viewName = match[1]; // only the chunk before any '&' + const viewUrl = `views/${viewName}.html`; + + // Now dynamically load that partial, then call its init function + await loadView(viewUrl); + const initFn = viewInitRegistry[viewName]; + if (typeof initFn === "function") { + initFn(); + } + } catch (error) { + console.error("Failed to handle hash change:", error); } - - const viewName = match[1]; // only the chunk before any '&' - const viewUrl = `views/${viewName}.html`; - - // Now dynamically load that partial, then call its init function - import("./viewManager.js").then(({ loadView, viewInitRegistry }) => { - loadView(viewUrl).then(() => { - const initFn = viewInitRegistry[viewName]; - if (typeof initFn === "function") { - initFn(); - } - }); - }); } From 8d47b3219345e5144150e96b20dd6372be4b4642 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 06:17:24 -0400 Subject: [PATCH 10/82] Fix Nostr view counter filters --- js/nostr.js | 147 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 34 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index e86ffc65..00c8dc1d 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -278,7 +278,7 @@ function normalizePointerInput(pointer) { return { type, value: trimmed, relay: null }; } -function resolveVideoViewPointerValue(pointer) { +function resolveVideoViewPointer(pointer) { const normalized = normalizePointerInput(pointer); if (!normalized || typeof normalized.value !== "string") { throw new Error("Invalid video pointer supplied for view lookup."); @@ -289,19 +289,58 @@ function resolveVideoViewPointerValue(pointer) { throw new Error("Invalid video pointer supplied for view lookup."); } - return value; + 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 createVideoViewEventFilter(pointer) { - const pointerValue = resolveVideoViewPointerValue(pointer); - return { +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_KIND], "#t": ["view"], - "#video": [pointerValue], }; + + if (resolved.type === "a") { + pointerFilter["#a"] = [resolved.value]; + } else { + pointerFilter["#e"] = [resolved.value]; + } + + const legacyFilter = { + kinds: [WATCH_HISTORY_KIND], + "#t": ["view"], + "#video": [resolved.value], + }; + + return { pointer: resolved, filters: [pointerFilter, legacyFilter] }; } -function isVideoViewEvent(event, pointerValue) { +function isVideoViewEvent(event, pointer) { if (!event || typeof event !== "object") { return false; } @@ -312,7 +351,10 @@ function isVideoViewEvent(event, pointerValue) { const tags = Array.isArray(event.tags) ? event.tags : []; let hasViewTag = false; - let hasVideoTag = false; + let matchesPointer = false; + + const pointerValue = typeof pointer?.value === "string" ? pointer.value : ""; + const pointerType = pointer?.type === "a" ? "a" : "e"; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { @@ -328,16 +370,30 @@ function isVideoViewEvent(event, pointerValue) { if (!hasViewTag && label === "t" && value === "view") { hasViewTag = true; - } else if (!hasVideoTag && label === "video" && value === pointerValue) { - hasVideoTag = true; + continue; } - if (hasViewTag && hasVideoTag) { - return true; + if (matchesPointer || !pointerValue) { + continue; + } + + if (label === "video" && value === pointerValue) { + matchesPointer = true; + continue; + } + + if (pointerType === "a" && label === "a" && value === pointerValue) { + matchesPointer = true; + continue; + } + + if (pointerType === "e" && label === "e" && value === pointerValue) { + matchesPointer = true; + continue; } } - return false; + return hasViewTag && matchesPointer; } function extractPointerItemsFromEvent(event) { @@ -2133,9 +2189,19 @@ class NostrClient { ) : []; + 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]; + const tags = [ ["t", "view"], ["video", pointer.value], + pointerTag, ...additionalTags, ]; @@ -2210,20 +2276,24 @@ class NostrClient { return []; } - const filter = createVideoViewEventFilter(pointer); - const pointerValue = filter["#video"][0]; + const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters( + pointer + ); const { since, until, limit, relays } = options || {}; - 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); + 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 @@ -2234,7 +2304,7 @@ class NostrClient { let rawResults; try { - rawResults = await this.pool.list(relayList, [filter]); + rawResults = await this.pool.list(relayList, filters); } catch (error) { if (isDevMode) { console.warn("[nostr] Failed to list video view events:", error); @@ -2267,7 +2337,7 @@ class NostrClient { const order = []; for (const event of flattened) { - if (!isVideoViewEvent(event, pointerValue)) { + if (!isVideoViewEvent(event, pointerDescriptor)) { continue; } @@ -2319,11 +2389,16 @@ class NostrClient { return () => {}; } - const filter = createVideoViewEventFilter(pointer); - const pointerValue = filter["#video"][0]; + const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters( + pointer + ); if (Number.isFinite(options?.since)) { - filter.since = Math.floor(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 @@ -2336,7 +2411,7 @@ class NostrClient { let subscription; try { - subscription = this.pool.sub(relayList, [filter]); + subscription = this.pool.sub(relayList, filters); } catch (error) { if (isDevMode) { console.warn("[nostr] Failed to open video view subscription:", error); @@ -2346,7 +2421,7 @@ class NostrClient { if (onEvent) { subscription.on("event", (event) => { - if (isVideoViewEvent(event, pointerValue)) { + if (isVideoViewEvent(event, pointerDescriptor)) { try { onEvent(event); } catch (error) { @@ -2394,7 +2469,11 @@ class NostrClient { }; } - const filter = createVideoViewEventFilter(pointer); + 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 relayList = Array.isArray(options?.relays) && options.relays.length ? options.relays : undefined; @@ -2416,7 +2495,7 @@ class NostrClient { } try { - const result = await this.countEventsAcrossRelays([filter], { + const result = await this.countEventsAcrossRelays([pointerFilter], { relays: relayList, timeoutMs: options?.timeoutMs, }); From a20dc838dcd09e4c321940164eb08c00d942190f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:16:22 -0400 Subject: [PATCH 11/82] Adjust relay count aggregation for view totals --- js/nostr.js | 50 +++++++++++++++++---- js/viewCounter.js | 14 ++++-- tests/view-counter.test.mjs | 88 +++++++++++++++++++++++++++++++++---- 3 files changed, 133 insertions(+), 19 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 00c8dc1d..7651ae9c 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2465,6 +2465,7 @@ class NostrClient { return { total: Array.isArray(events) ? events.length : 0, perRelay: [], + best: null, fallback: true, }; } @@ -2539,6 +2540,7 @@ class NostrClient { return { total: Array.isArray(events) ? events.length : 0, perRelay: [], + best: null, fallback: true, }; } @@ -4766,7 +4768,7 @@ class NostrClient { async countEventsAcrossRelays(filters, options = {}) { const normalizedFilters = this.normalizeCountFilters(filters); if (!normalizedFilters.length) { - return { total: 0, perRelay: [] }; + return { total: 0, best: null, perRelay: [] }; } const relayList = @@ -4776,7 +4778,7 @@ class NostrClient { ? this.relays : RELAY_URLS; - const perRelay = await Promise.all( + const perRelayResults = await Promise.all( relayList.map(async (url) => { try { const frame = await this.sendRawCountFrame(url, normalizedFilters, { @@ -4793,15 +4795,47 @@ class NostrClient { }) ); - const total = perRelay.reduce((sum, entry) => { + let bestEstimate = null; + const perRelay = perRelayResults.map((entry) => { if (!entry || !entry.ok) { - return sum; + return entry; } - const value = Number(entry.count); - return Number.isFinite(value) && value > 0 ? sum + value : sum; - }, 0); - return { total, perRelay }; + 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, + }; } /** diff --git a/js/viewCounter.js b/js/viewCounter.js index cd38164c..72ab3350 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -485,10 +485,18 @@ async function hydratePointer(key, listeners) { mutated = applyEventToState(key, event) || mutated; } } - if (countResult && Number.isFinite(countResult.total)) { + const bestCount = Number.isFinite(countResult?.best?.count) + ? Number(countResult.best.count) + : Number.isFinite(countResult?.total) + ? Number(countResult.total) + : null; + + if (bestCount !== null) { const state = ensurePointerState(key); - if (countResult.total > state.total) { - state.total = Number(countResult.total); + const shouldUpdate = + !countResult?.fallback || bestCount >= state.total || state.total === 0; + if (shouldUpdate && state.total !== bestCount) { + state.total = bestCount; state.lastSyncedAt = Date.now(); mutated = true; } diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 043cb8be..897ccdda 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -46,7 +46,7 @@ const { nostrClient } = await import("../js/nostr.js"); function createMockNostrHarness() { const storedEvents = new Map(); - const customTotals = new Map(); + const customCountResults = new Map(); const subscribers = new Map(); const metrics = { list: 0, count: 0 }; @@ -108,11 +108,37 @@ function createMockNostrHarness() { const countVideoViewEvents = async (pointer) => { metrics.count += 1; const key = pointerKeyFromInput(pointer); - if (customTotals.has(key)) { - return { total: customTotals.get(key) }; + if (customCountResults.has(key)) { + const stored = customCountResults.get(key); + if (stored && typeof stored === "object" && !Array.isArray(stored)) { + const totalValue = Number(stored.total); + const normalizedTotal = + Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : 0; + const perRelay = Array.isArray(stored.perRelay) + ? stored.perRelay.map((entry) => + entry && typeof entry === "object" ? { ...entry } : entry + ) + : []; + const result = { + total: normalizedTotal, + perRelay, + best: + stored.best && typeof stored.best === "object" + ? { ...stored.best } + : null, + }; + if (stored.fallback) { + result.fallback = true; + } + return result; + } + const numericTotal = Number(stored); + const normalizedTotal = + Number.isFinite(numericTotal) && numericTotal >= 0 ? numericTotal : 0; + return { total: normalizedTotal, perRelay: [], best: null }; } const events = storedEvents.get(key) || []; - return { total: events.length }; + return { total: events.length, perRelay: [], best: null }; }; const subscribeVideoViewEvents = (pointer, options = {}) => { @@ -152,16 +178,22 @@ function createMockNostrHarness() { }; const setCountTotal = (key, total) => { - if (Number.isFinite(total)) { - customTotals.set(key, Number(total)); + if ( + typeof total === "object" && + total !== null && + !Array.isArray(total) + ) { + customCountResults.set(key, JSON.parse(JSON.stringify(total))); + } else if (Number.isFinite(total)) { + customCountResults.set(key, Number(total)); } else { - customTotals.delete(key); + customCountResults.delete(key); } }; const reset = () => { storedEvents.clear(); - customTotals.clear(); + customCountResults.clear(); subscribers.clear(); resetMetrics(); }; @@ -319,6 +351,45 @@ async function testHydrationSkipsStaleEventsAndRollsOff() { } } +async function testRelayCountAggregationUsesBestEstimate() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const pointer = { type: "e", value: "view-counter-multi-relay" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, { + total: 5, + best: { relay: "wss://relay.alpha", count: 5 }, + perRelay: [ + { url: "wss://relay.alpha", ok: true, count: 5 }, + { url: "wss://relay.beta", ok: true, count: 5 }, + ], + }); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + await flushPromises(); + + const final = updates.at(-1); + assert.ok(final, "expected hydration update for aggregated relay counts"); + assert.equal( + final.total, + 5, + "identical relay COUNT responses should not be double-counted" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + async function testLocalIngestNotifiesImmediately() { localStorage.clear(); harness.reset(); @@ -458,5 +529,6 @@ await testDedupesWithinWindow(); await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); +await testRelayCountAggregationUsesBestEstimate(); console.log("View counter tests completed successfully."); From eae6b7b925705394a464e3da265f9eb6cf7d269e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:19:42 -0400 Subject: [PATCH 12/82] docs: outline view event session flow --- docs/nostr-analytics.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index 87e51465..31b9362a 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -7,3 +7,19 @@ BitVid's view counter emits Nostr events so operators can track engagement witho - `VIEW_COUNT_CACHE_TTL_MS` (default: `5 * 60 * 1000`): defines how long cached aggregates remain trustworthy before clients refresh them. Lower values surface spikes faster, while higher ones smooth traffic for relay-friendly dashboards. Most operators can ship with the defaults—24-hour deduplication, a 90-day backfill horizon, and five-minute cache TTLs match what we run in production. Deviate only if your relays have unusual load constraints or your reporting needs stricter fidelity. + +## View Event Lifecycle + +### Anonymous session keys + +Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr.js#L2011-L2105) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback. + +### Publish latency expectations + +When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through `recordVideoView`. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing. + +To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. + +### Hydration, COUNT, and UI sync + +Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload. From ca4c53e0816ab12c255fc4784ca44508f604d010 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:14:55 -0400 Subject: [PATCH 13/82] Refresh watch history when auth changes --- js/app.js | 36 +++++++++++++++- js/historyView.js | 102 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index 73dff5fa..8fa09e81 100644 --- a/js/app.js +++ b/js/app.js @@ -6465,7 +6465,8 @@ class bitvidApp { const nextActivePubkey = normalizedPubkey || (typeof pubkey === "string" ? pubkey.trim() : ""); - if (previousActivePubkey !== nextActivePubkey) { + const identityChanged = previousActivePubkey !== nextActivePubkey; + if (identityChanged) { this.resetViewLoggingState(); } @@ -6476,6 +6477,21 @@ class bitvidApp { } this.currentUserNpub = this.safeEncodeNpub(this.pubkey); + if ( + identityChanged && + typeof window !== "undefined" && + typeof window.dispatchEvent === "function" + ) { + window.dispatchEvent( + new CustomEvent("bitvid:auth-changed", { + detail: { + pubkey: nextActivePubkey || null, + previousPubkey: previousActivePubkey || null, + }, + }) + ); + } + let savedProfilesMutated = false; if (normalizedPubkey) { const cacheEntry = this.profileCache.get(normalizedPubkey); @@ -6537,10 +6553,28 @@ class bitvidApp { */ async logout() { nostrClient.logout(); + const previousPubkey = + this.normalizeHexPubkey(this.pubkey) || + (typeof this.pubkey === "string" ? this.pubkey.trim() : ""); this.resetViewLoggingState(); this.pubkey = null; this.currentUserNpub = null; + if ( + previousPubkey && + typeof window !== "undefined" && + typeof window.dispatchEvent === "function" + ) { + window.dispatchEvent( + new CustomEvent("bitvid:auth-changed", { + detail: { + pubkey: null, + previousPubkey, + }, + }) + ); + } + this.persistActiveProfileSelection(null, { persist: true }); userBlocks.reset(); diff --git a/js/historyView.js b/js/historyView.js index 13fa4ff5..4c306b39 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -484,6 +484,48 @@ export function createWatchHistoryRenderer(config = {}) { const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; + let authChangeListener = null; + let authRefreshPromise = null; + let scheduleAuthRefresh = null; + + const normalizeActorHint = (actorHint) => { + if (typeof actorHint === "string") { + const trimmed = actorHint.trim(); + if (trimmed) { + return trimmed; + } + } + return null; + }; + + const attachAuthChangeListener = () => { + if (authChangeListener || typeof window === "undefined") { + return; + } + + authChangeListener = (event) => { + const detail = event?.detail || {}; + const actorHint = + typeof detail.pubkey === "string" ? detail.pubkey : null; + try { + scheduleAuthRefresh?.(actorHint ?? null); + } catch (error) { + console.warn("[historyView] Failed to schedule auth refresh:", error); + } + }; + + window.addEventListener("bitvid:auth-changed", authChangeListener); + }; + + const detachAuthChangeListener = () => { + if (!authChangeListener || typeof window === "undefined") { + return; + } + + window.removeEventListener("bitvid:auth-changed", authChangeListener); + authChangeListener = null; + }; + const selectors = { view: viewSelector, grid: gridSelector, @@ -1106,6 +1148,51 @@ export function createWatchHistoryRenderer(config = {}) { }); }; + scheduleAuthRefresh = (actorHint) => { + const normalizedActor = normalizeActorHint(actorHint); + + const perform = async () => { + const nextActor = normalizedActor || null; + + try { + const { view } = getElements(); + if (!view) { + state.actor = nextActor; + state.snapshotFingerprint = null; + state.initialized = false; + state.resolvedVideos = []; + state.hasMore = true; + state.isLoading = false; + cleanupObservers(); + return; + } + + await runInitialLoad({ actor: nextActor }); + } catch (error) { + console.error( + "[historyView] Failed to refresh watch history after auth change:", + error + ); + } + }; + + const pendingRefresh = + authRefreshPromise !== null + ? authRefreshPromise.then(perform, perform) + : perform(); + + let currentPromise; + const finalize = () => { + if (authRefreshPromise === currentPromise) { + authRefreshPromise = null; + } + }; + + currentPromise = pendingRefresh.finally(finalize); + authRefreshPromise = currentPromise; + return currentPromise; + }; + const runInitialLoad = async (options = {}) => { const { actor: actorHint = null, prefetched = null, fingerprintOverride } = typeof options === "object" && options !== null ? options : {}; @@ -1204,6 +1291,8 @@ export function createWatchHistoryRenderer(config = {}) { }); }; + attachAuthChangeListener(); + return { async init() { debugLog("init called"); @@ -1313,6 +1402,7 @@ export function createWatchHistoryRenderer(config = {}) { debugLog("destroy called"); cleanupObservers(); cleanupGridHandler(); + detachAuthChangeListener(); state.initialized = false; state.resolvedVideos = []; state.hasMore = true; @@ -1322,6 +1412,9 @@ export function createWatchHistoryRenderer(config = {}) { resetUiState(); setLoadingVisible(false); }, + async refresh(actorHint = null) { + await scheduleAuthRefresh(actorHint); + }, render() { renderResolvedVideos(); }, @@ -1340,7 +1433,10 @@ export async function initHistoryView() { await watchHistoryRenderer.init(); } -if (!window.bitvid) { - window.bitvid = {}; +if (typeof window !== "undefined") { + if (!window.bitvid) { + window.bitvid = {}; + } + window.bitvid.initHistoryView = initHistoryView; + window.bitvid.watchHistoryRenderer = watchHistoryRenderer; } -window.bitvid.initHistoryView = initHistoryView; From c5e95c214364d6123747d7e612a21d53d2d85bac Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:24:00 -0400 Subject: [PATCH 14/82] Improve view event dedupe tagging and logging --- js/nostr.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 7651ae9c..943ffa87 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -9,6 +9,7 @@ import { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, + VIEW_COUNT_DEDUPE_WINDOW_SECONDS, } from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -340,6 +341,41 @@ function createVideoViewEventFilters(pointer) { return { pointer: resolved, filters: [pointerFilter, legacyFilter] }; } +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 deriveViewEventDedupTag(actorPubkey, pointer, createdAtSeconds) { + if (typeof actorPubkey !== "string") { + return null; + } + + const normalizedActor = actorPubkey.trim().toLowerCase(); + if (!normalizedActor) { + return null; + } + + const pointerValueRaw = + typeof pointer?.value === "string" ? pointer.value.trim() : ""; + if (!pointerValueRaw) { + return null; + } + + const pointerType = pointer?.type === "a" ? "a" : "e"; + const pointerValue = pointerValueRaw.toLowerCase(); + const bucket = deriveViewEventBucketIndex(createdAtSeconds); + const scope = pointerType === "a" ? `a:${pointerValue}` : `e:${pointerValue}`; + + return `bitvid:view:${normalizedActor}:${scope}:${bucket}`; +} + function isVideoViewEvent(event, pointer) { if (!event || typeof event !== "object") { return false; @@ -2205,6 +2241,18 @@ class NostrClient { ...additionalTags, ]; + const dedupeTagValue = deriveViewEventDedupTag( + actorPubkey, + pointer, + createdAt + ); + if ( + dedupeTagValue && + !tags.some((tag) => tag[0] === "d" && tag[1] === dedupeTagValue) + ) { + tags.push(["d", dedupeTagValue]); + } + if ( usingSessionActor && !tags.some((tag) => tag[0] === "session" && tag[1] === "true") @@ -2263,12 +2311,26 @@ class NostrClient { relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) ); - const success = publishResults.some((result) => result.success); - if (!success) { + const acceptedRelays = publishResults + .filter((result) => result.success) + .map((result) => result.url) + .filter((url) => typeof url === "string" && url); + const success = acceptedRelays.length > 0; + if (success) { + 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 }; + return { + ok: success, + event: signedEvent, + results: publishResults, + acceptedRelays, + }; } async listVideoViewEvents(pointer, options = {}) { @@ -2537,8 +2599,20 @@ class NostrClient { ? 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: Array.isArray(events) ? events.length : 0, + total: uniqueCount, perRelay: [], best: null, fallback: true, From 434f5c3fa5a403a089ad763691f73b45678daf84 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:55:31 -0400 Subject: [PATCH 15/82] Handle nostr readiness and count fallback --- js/adminListStore.js | 58 ++++++++++++++++++------ js/nostr.js | 105 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index 3b9dd969..ec321ca7 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -194,8 +194,27 @@ function clearLegacyStorageFor(listKey) { } } -function ensureNostrReady() { - if (!nostrClient || !nostrClient.pool) { +async function ensureNostrReady() { + if (!nostrClient) { + throw createError( + "nostr-unavailable", + "Nostr relay pool is not initialized." + ); + } + + if (typeof nostrClient.waitForPool === "function") { + try { + await nostrClient.waitForPool(); + } catch (error) { + throw createError( + "nostr-unavailable", + "Nostr relay pool is not initialized.", + error + ); + } + } + + if (!nostrClient.pool) { throw createError( "nostr-unavailable", "Nostr relay pool is not initialized." @@ -257,7 +276,7 @@ function extractNpubsFromEvent(event) { } async function loadNostrList(identifier) { - const relays = ensureNostrReady(); + const relays = await ensureNostrReady(); const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; const filter = { kinds: [30000], @@ -401,14 +420,27 @@ function publishToRelay(url, signedEvent, listKey) { } if (typeof pub.on === "function") { - pub.on("ok", () => finalize(true)); - pub.on("seen", () => finalize(true)); - pub.on("failed", (reason) => { - const error = - reason instanceof Error ? reason : new Error(String(reason || "failed")); - finalize(false, error); - }); - return; + try { + pub.on("ok", () => finalize(true)); + pub.on("seen", () => finalize(true)); + pub.on("failed", (reason) => { + const error = + reason instanceof Error + ? reason + : new Error(String(reason || "failed")); + finalize(false, error); + }); + return; + } catch (listenerError) { + if (isDevMode) { + console.warn( + `[adminListStore] Failed to wire publish listeners for ${url}:`, + listenerError + ); + } + finalize(true); + return; + } } if (typeof pub.then === "function") { @@ -433,7 +465,7 @@ function publishToRelay(url, signedEvent, listKey) { } async function persistNostrState(actorNpub, updates = {}) { - ensureNostrReady(); + await ensureNostrReady(); const extension = window?.nostr; if (!extension || typeof extension.signEvent !== "function") { throw createError( @@ -510,7 +542,7 @@ async function persistNostrState(actorNpub, updates = {}) { throw createError("signature-failed", "Failed to sign admin list event.", error); } - const relays = ensureNostrReady(); + const relays = await ensureNostrReady(); const publishResults = await Promise.all( relays.map((url) => publishToRelay(url, signedEvent, listKey)) ); diff --git a/js/nostr.js b/js/nostr.js index 943ffa87..334ba661 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -36,6 +36,7 @@ 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 COUNT_UNSUPPORTED_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes // To limit error spam let errorLogCount = 0; @@ -55,6 +56,36 @@ function logErrorOnce(message, eventContent = null) { } } +function createDeferred() { + let resolveFn; + let rejectFn; + let state = "pending"; + const promise = new Promise((resolve, reject) => { + resolveFn = (value) => { + if (state !== "pending") { + return; + } + state = "fulfilled"; + resolve(value); + }; + rejectFn = (error) => { + if (state !== "pending") { + return; + } + state = "rejected"; + reject(error); + }; + }); + + return { + promise, + resolve: resolveFn, + reject: rejectFn, + isSettled: () => state !== "pending", + getState: () => state, + }; +} + function withNip07Timeout(operation) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { @@ -1108,6 +1139,9 @@ class NostrClient { this.watchHistoryRepublishTimers = new Map(); this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; this.countRequestCounter = 0; + this.countUnsupportedRelays = new Map(); + + this._poolReadyDeferred = createDeferred(); } restoreLocalData() { @@ -3597,13 +3631,19 @@ class NostrClient { .filter((r) => r.success) .map((r) => r.url); if (successfulRelays.length === 0) { - throw new Error("No relays connected"); + const error = new Error("No relays connected"); + this._poolReadyDeferred.reject(error); + this.pool = null; + throw error; } if (isDevMode) { console.log(`Connected to ${successfulRelays.length} relay(s)`); } + this._poolReadyDeferred.resolve(); } catch (err) { console.error("Nostr init failed:", err); + this._poolReadyDeferred.reject(err); + this.pool = null; throw err; } } @@ -4699,6 +4739,46 @@ class NostrClient { return normalized; } + markCountUnsupported(relayUrl, ttlMs = COUNT_UNSUPPORTED_COOLDOWN_MS) { + const normalizedUrl = typeof relayUrl === "string" ? relayUrl.trim() : ""; + if (!normalizedUrl) { + return; + } + const now = Date.now(); + const expiry = now + Math.max(0, Number(ttlMs) || 0); + this.countUnsupportedRelays.set(normalizedUrl, expiry); + } + + isCountTemporarilyUnsupported(relayUrl) { + const normalizedUrl = typeof relayUrl === "string" ? relayUrl.trim() : ""; + if (!normalizedUrl) { + return false; + } + const expiry = this.countUnsupportedRelays.get(normalizedUrl); + if (!expiry) { + return false; + } + if (Date.now() >= expiry) { + this.countUnsupportedRelays.delete(normalizedUrl); + return false; + } + return true; + } + + waitForPool(timeoutMs) { + if (this.pool && this._poolReadyDeferred.isSettled()) { + return Promise.resolve(); + } + + const promise = this._poolReadyDeferred.promise; + const numericTimeout = Number(timeoutMs); + if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) { + return promise; + } + + return withRequestTimeout(promise, numericTimeout, null, "Timed out waiting for Nostr pool readiness"); + } + generateCountRequestId(prefix = "count") { this.countRequestCounter += 1; if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) { @@ -4818,6 +4898,7 @@ class NostrClient { } else if (typeof relay.count === "function") { countPromise = relay.count(normalizedFilters, { id: requestId }); } else { + this.markCountUnsupported(normalizedUrl); throw new Error( `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` ); @@ -4854,6 +4935,13 @@ class NostrClient { const perRelayResults = await Promise.all( relayList.map(async (url) => { + if (this.isCountTemporarilyUnsupported(url)) { + return { + url, + ok: false, + error: new Error("COUNT unsupported (cached)"), + }; + } try { const frame = await this.sendRawCountFrame(url, normalizedFilters, { timeoutMs: options.timeoutMs, @@ -4861,8 +4949,19 @@ class NostrClient { const count = this.extractCountValue(frame?.[2]); return { url, ok: true, frame, count }; } catch (error) { - if (isDevMode) { - console.warn(`[nostr] COUNT request failed on ${url}:`, error); + if ( + error && + typeof error.message === "string" && + error.message.includes("does not support COUNT frames") + ) { + this.markCountUnsupported(url); + logErrorOnce(`[nostr] COUNT disabled for ${url} (no support).`); + } else if (isDevMode) { + const message = + error && typeof error.message === "string" + ? error.message + : String(error); + logErrorOnce(`[nostr] COUNT request failed on ${url}: ${message}`); } return { url, ok: false, error }; } From 713e4eeab60309c45b80e3f0a37ed91a5a5e0919 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:52:42 -0400 Subject: [PATCH 16/82] Revert "Handle nostr readiness and count fallback" --- js/adminListStore.js | 58 ++++++------------------ js/nostr.js | 105 ++----------------------------------------- 2 files changed, 16 insertions(+), 147 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index ec321ca7..3b9dd969 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -194,27 +194,8 @@ function clearLegacyStorageFor(listKey) { } } -async function ensureNostrReady() { - if (!nostrClient) { - throw createError( - "nostr-unavailable", - "Nostr relay pool is not initialized." - ); - } - - if (typeof nostrClient.waitForPool === "function") { - try { - await nostrClient.waitForPool(); - } catch (error) { - throw createError( - "nostr-unavailable", - "Nostr relay pool is not initialized.", - error - ); - } - } - - if (!nostrClient.pool) { +function ensureNostrReady() { + if (!nostrClient || !nostrClient.pool) { throw createError( "nostr-unavailable", "Nostr relay pool is not initialized." @@ -276,7 +257,7 @@ function extractNpubsFromEvent(event) { } async function loadNostrList(identifier) { - const relays = await ensureNostrReady(); + const relays = ensureNostrReady(); const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; const filter = { kinds: [30000], @@ -420,27 +401,14 @@ function publishToRelay(url, signedEvent, listKey) { } if (typeof pub.on === "function") { - try { - pub.on("ok", () => finalize(true)); - pub.on("seen", () => finalize(true)); - pub.on("failed", (reason) => { - const error = - reason instanceof Error - ? reason - : new Error(String(reason || "failed")); - finalize(false, error); - }); - return; - } catch (listenerError) { - if (isDevMode) { - console.warn( - `[adminListStore] Failed to wire publish listeners for ${url}:`, - listenerError - ); - } - finalize(true); - return; - } + pub.on("ok", () => finalize(true)); + pub.on("seen", () => finalize(true)); + pub.on("failed", (reason) => { + const error = + reason instanceof Error ? reason : new Error(String(reason || "failed")); + finalize(false, error); + }); + return; } if (typeof pub.then === "function") { @@ -465,7 +433,7 @@ function publishToRelay(url, signedEvent, listKey) { } async function persistNostrState(actorNpub, updates = {}) { - await ensureNostrReady(); + ensureNostrReady(); const extension = window?.nostr; if (!extension || typeof extension.signEvent !== "function") { throw createError( @@ -542,7 +510,7 @@ async function persistNostrState(actorNpub, updates = {}) { throw createError("signature-failed", "Failed to sign admin list event.", error); } - const relays = await ensureNostrReady(); + const relays = ensureNostrReady(); const publishResults = await Promise.all( relays.map((url) => publishToRelay(url, signedEvent, listKey)) ); diff --git a/js/nostr.js b/js/nostr.js index 334ba661..943ffa87 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -36,7 +36,6 @@ 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 COUNT_UNSUPPORTED_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes // To limit error spam let errorLogCount = 0; @@ -56,36 +55,6 @@ function logErrorOnce(message, eventContent = null) { } } -function createDeferred() { - let resolveFn; - let rejectFn; - let state = "pending"; - const promise = new Promise((resolve, reject) => { - resolveFn = (value) => { - if (state !== "pending") { - return; - } - state = "fulfilled"; - resolve(value); - }; - rejectFn = (error) => { - if (state !== "pending") { - return; - } - state = "rejected"; - reject(error); - }; - }); - - return { - promise, - resolve: resolveFn, - reject: rejectFn, - isSettled: () => state !== "pending", - getState: () => state, - }; -} - function withNip07Timeout(operation) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { @@ -1139,9 +1108,6 @@ class NostrClient { this.watchHistoryRepublishTimers = new Map(); this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; this.countRequestCounter = 0; - this.countUnsupportedRelays = new Map(); - - this._poolReadyDeferred = createDeferred(); } restoreLocalData() { @@ -3631,19 +3597,13 @@ class NostrClient { .filter((r) => r.success) .map((r) => r.url); if (successfulRelays.length === 0) { - const error = new Error("No relays connected"); - this._poolReadyDeferred.reject(error); - this.pool = null; - throw error; + throw new Error("No relays connected"); } if (isDevMode) { console.log(`Connected to ${successfulRelays.length} relay(s)`); } - this._poolReadyDeferred.resolve(); } catch (err) { console.error("Nostr init failed:", err); - this._poolReadyDeferred.reject(err); - this.pool = null; throw err; } } @@ -4739,46 +4699,6 @@ class NostrClient { return normalized; } - markCountUnsupported(relayUrl, ttlMs = COUNT_UNSUPPORTED_COOLDOWN_MS) { - const normalizedUrl = typeof relayUrl === "string" ? relayUrl.trim() : ""; - if (!normalizedUrl) { - return; - } - const now = Date.now(); - const expiry = now + Math.max(0, Number(ttlMs) || 0); - this.countUnsupportedRelays.set(normalizedUrl, expiry); - } - - isCountTemporarilyUnsupported(relayUrl) { - const normalizedUrl = typeof relayUrl === "string" ? relayUrl.trim() : ""; - if (!normalizedUrl) { - return false; - } - const expiry = this.countUnsupportedRelays.get(normalizedUrl); - if (!expiry) { - return false; - } - if (Date.now() >= expiry) { - this.countUnsupportedRelays.delete(normalizedUrl); - return false; - } - return true; - } - - waitForPool(timeoutMs) { - if (this.pool && this._poolReadyDeferred.isSettled()) { - return Promise.resolve(); - } - - const promise = this._poolReadyDeferred.promise; - const numericTimeout = Number(timeoutMs); - if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) { - return promise; - } - - return withRequestTimeout(promise, numericTimeout, null, "Timed out waiting for Nostr pool readiness"); - } - generateCountRequestId(prefix = "count") { this.countRequestCounter += 1; if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) { @@ -4898,7 +4818,6 @@ class NostrClient { } else if (typeof relay.count === "function") { countPromise = relay.count(normalizedFilters, { id: requestId }); } else { - this.markCountUnsupported(normalizedUrl); throw new Error( `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` ); @@ -4935,13 +4854,6 @@ class NostrClient { const perRelayResults = await Promise.all( relayList.map(async (url) => { - if (this.isCountTemporarilyUnsupported(url)) { - return { - url, - ok: false, - error: new Error("COUNT unsupported (cached)"), - }; - } try { const frame = await this.sendRawCountFrame(url, normalizedFilters, { timeoutMs: options.timeoutMs, @@ -4949,19 +4861,8 @@ class NostrClient { const count = this.extractCountValue(frame?.[2]); return { url, ok: true, frame, count }; } catch (error) { - if ( - error && - typeof error.message === "string" && - error.message.includes("does not support COUNT frames") - ) { - this.markCountUnsupported(url); - logErrorOnce(`[nostr] COUNT disabled for ${url} (no support).`); - } else if (isDevMode) { - const message = - error && typeof error.message === "string" - ? error.message - : String(error); - logErrorOnce(`[nostr] COUNT request failed on ${url}: ${message}`); + if (isDevMode) { + console.warn(`[nostr] COUNT request failed on ${url}:`, error); } return { url, ok: false, error }; } From 0e29b07d5bbda0850d136a4a028ab186ef9b16a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:21:59 -0400 Subject: [PATCH 17/82] refactor: centralize nostr event schemas --- docs/nostr-event-schemas.md | 56 ++++ js/adminListStore.js | 32 +-- js/nostr.js | 85 ++---- js/nostrEventSchemas.js | 531 ++++++++++++++++++++++++++++++++++++ js/subscriptions.js | 12 +- js/userBlocks.js | 9 +- 6 files changed, 634 insertions(+), 91 deletions(-) create mode 100644 docs/nostr-event-schemas.md create mode 100644 js/nostrEventSchemas.js diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md new file mode 100644 index 00000000..7b295cf5 --- /dev/null +++ b/docs/nostr-event-schemas.md @@ -0,0 +1,56 @@ +# Nostr event schemas + +BitVid now centralizes every note that it publishes to Nostr in +[`js/nostrEventSchemas.js`](../js/nostrEventSchemas.js). The module defines the +kind, required tags, and content format for each note type so troubleshooting +no longer requires hunting through the codebase. It also exposes helpers for +building events and for overriding the schema at runtime when you need to +experiment. + +## Runtime helpers + +```js +import { + NOTE_TYPES, + getNostrEventSchema, + setNostrEventSchemaOverrides, + buildVideoPostEvent, +} from "./nostrEventSchemas.js"; + +// Inspect the current schema +console.log(getNostrEventSchema(NOTE_TYPES.VIDEO_POST)); + +// Temporarily override the kind while debugging +setNostrEventSchemaOverrides({ + [NOTE_TYPES.VIDEO_POST]: { kind: 30000 }, +}); + +// Build an event with the active schema +const event = buildVideoPostEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + dTagValue: "debug-video", + content: { version: 3, title: "Test", videoRootId: "debug" }, +}); +``` + +In the browser you can call the same helpers from DevTools via +`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`. + +## Event catalogue + +| Note | Kind (default) | Tags | Content format | +| --- | --- | --- | --- | +| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | +| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', ]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message | +| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, `['head','1']` + `['a',
]` pointers on the first chunk | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | +| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | +| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` | +| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | +| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | +| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | + +If you introduce a new Nostr feature, add its schema to +`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing +builders inherit the same debugging knobs. diff --git a/js/adminListStore.js b/js/adminListStore.js index 3b9dd969..428a8e9d 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -8,12 +8,10 @@ import { isDevMode, } from "./config.js"; import { nostrClient } from "./nostr.js"; - -const LIST_IDENTIFIERS = { - editors: "editors", - whitelist: "whitelist", - blacklist: "blacklist", -}; +import { + buildAdminListEvent, + ADMIN_LIST_IDENTIFIERS, +} from "./nostrEventSchemas.js"; const LEGACY_STORAGE_KEYS = { editors: "bitvid_admin_editors", @@ -319,18 +317,15 @@ async function loadNostrList(identifier) { async function loadNostrState() { const [editors, whitelist, blacklist] = await Promise.all([ - loadNostrList(LIST_IDENTIFIERS.editors), - loadNostrList(LIST_IDENTIFIERS.whitelist), - loadNostrList(LIST_IDENTIFIERS.blacklist), + loadNostrList(ADMIN_LIST_IDENTIFIERS.editors), + loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist), + loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist), ]); return sanitizeAdminState({ editors, whitelist, blacklist }); } function buildListEvent(listKey, npubs, actorHex) { - const identifier = LIST_IDENTIFIERS[listKey]; - const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; - const hexPubkeys = Array.from( new Set(npubs.map((npub) => { try { @@ -360,18 +355,11 @@ function buildListEvent(listKey, npubs, actorHex) { } } - const tags = [["d", dTagValue]]; - hexPubkeys.forEach((hex) => { - tags.push(["p", hex]); - }); - - return { - kind: 30000, + return buildAdminListEvent(listKey, { pubkey: actorHex, created_at: Math.floor(Date.now() / 1000), - tags, - content: "", - }; + hexPubkeys, + }); } function publishToRelay(url, signedEvent, listKey) { diff --git a/js/nostr.js b/js/nostr.js index 943ffa87..95da8ecc 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -16,6 +16,12 @@ 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, + buildWatchHistoryChunkEvent, +} from "./nostrEventSchemas.js"; /** * The usual relays @@ -1935,34 +1941,19 @@ class NostrClient { const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER; - const tags = [ - ["d", chunkIdentifier], - ["encrypted", "nip04"], - ["snapshot", snapshotId], - ["chunk", String(index), String(totalChunks)], - ...pointerTags, - ]; - if (index === 0) { - tags.splice(2, 0, ["head", "1"]); - const pointerTagsForChunks = chunkAddresses - .map((address) => - typeof address === "string" && address - ? ["a", address] - : null - ) - .filter(Boolean); - tags.push(...pointerTagsForChunks); - } - // 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 = { - kind: WATCH_HISTORY_KIND, + const event = buildWatchHistoryChunkEvent({ pubkey: normalizedActor, created_at: baseTimestamp + (totalChunks - index - 1), - tags, + chunkIdentifier, + snapshotId, + chunkIndex: index, + totalChunks, + pointerTags, + chunkAddresses, content: encryptionResult.ciphertext, - }; + }); let signedEvent; if (useExtension) { @@ -2234,41 +2225,24 @@ class NostrClient { ? ["e", pointer.value, pointer.relay] : ["e", pointer.value]; - const tags = [ - ["t", "view"], - ["video", pointer.value], - pointerTag, - ...additionalTags, - ]; - const dedupeTagValue = deriveViewEventDedupTag( actorPubkey, pointer, createdAt ); - if ( - dedupeTagValue && - !tags.some((tag) => tag[0] === "d" && tag[1] === dedupeTagValue) - ) { - tags.push(["d", dedupeTagValue]); - } - - if ( - usingSessionActor && - !tags.some((tag) => tag[0] === "session" && tag[1] === "true") - ) { - tags.push(["session", "true"]); - } const content = typeof options.content === "string" ? options.content : ""; - const event = { - kind: WATCH_HISTORY_KIND, + const event = buildViewEvent({ pubkey: actorPubkey, created_at: createdAt, - tags, + pointerValue: pointer.value, + pointerTag, + dedupeTag: dedupeTagValue, + includeSessionTag: usingSessionActor, + additionalTags, content, - }; + }); let signedEvent = null; @@ -3918,16 +3892,12 @@ class NostrClient { contentObject.xs = finalXs; } - const event = { - kind: 30078, + const event = buildVideoPostEvent({ pubkey, created_at: createdAt, - tags: [ - ["t", "video"], - ["d", dTagValue], - ], - content: JSON.stringify(contentObject), - }; + dTagValue, + content: contentObject, + }); if (isDevMode) { console.log("Publish event with brand-new root:", videoRootId); @@ -3972,13 +3942,12 @@ class NostrClient { mirrorTags.push(["magnet", finalMagnet]); } - const mirrorEvent = { - kind: 1063, + const mirrorEvent = buildVideoMirrorEvent({ pubkey, created_at: createdAt, tags: mirrorTags, content: altText, - }; + }); if (isDevMode) { console.log("Prepared NIP-94 mirror event:", mirrorEvent); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js new file mode 100644 index 00000000..360754d1 --- /dev/null +++ b/js/nostrEventSchemas.js @@ -0,0 +1,531 @@ +import { + ADMIN_LIST_NAMESPACE, + isDevMode, + WATCH_HISTORY_KIND, + WATCH_HISTORY_LIST_IDENTIFIER, +} from "./config.js"; + +export const NOTE_TYPES = Object.freeze({ + VIDEO_POST: "videoPost", + VIDEO_MIRROR: "videoMirror", + VIEW_EVENT: "viewEvent", + WATCH_HISTORY_CHUNK: "watchHistoryChunk", + SUBSCRIPTION_LIST: "subscriptionList", + USER_BLOCK_LIST: "userBlockList", + ADMIN_MODERATION_LIST: "adminModerationList", + ADMIN_BLACKLIST: "adminBlacklist", + ADMIN_WHITELIST: "adminWhitelist", +}); + +export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions"; +export const BLOCK_LIST_IDENTIFIER = "user-blocks"; +export const ADMIN_LIST_IDENTIFIERS = Object.freeze({ + moderation: "editors", + editors: "editors", + whitelist: "whitelist", + blacklist: "blacklist", +}); + +const DEFAULT_APPEND_TAGS = []; + +const BASE_SCHEMAS = { + [NOTE_TYPES.VIDEO_POST]: { + type: NOTE_TYPES.VIDEO_POST, + label: "Video post", + kind: 30078, + topicTag: { name: "t", value: "video" }, + identifierTag: { name: "d" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "json", + schemaVersion: 3, + fields: [ + { key: "version", type: "number", required: true }, + { key: "title", type: "string", required: true }, + { key: "url", type: "string", required: false }, + { key: "magnet", type: "string", required: false }, + { key: "thumbnail", type: "string", required: false }, + { key: "description", type: "string", required: false }, + { key: "mode", type: "string", required: false }, + { key: "videoRootId", type: "string", required: true }, + { key: "deleted", type: "boolean", required: false }, + { key: "isPrivate", type: "boolean", required: false }, + { key: "enableComments", type: "boolean", required: false }, + { key: "ws", type: "string", required: false }, + { key: "xs", type: "string", required: false }, + ], + }, + }, + [NOTE_TYPES.VIDEO_MIRROR]: { + type: NOTE_TYPES.VIDEO_MIRROR, + label: "NIP-94 mirror", + kind: 1063, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: "Optional alt text carried alongside hosted URL metadata.", + }, + }, + [NOTE_TYPES.VIEW_EVENT]: { + type: NOTE_TYPES.VIEW_EVENT, + label: "View counter", + kind: WATCH_HISTORY_KIND, + topicTag: { name: "t", value: "view" }, + pointerTagName: "video", + identifierTag: { name: "d" }, + sessionTag: { name: "session", value: "true" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: "Optional plaintext content used for diagnostics.", + }, + }, + [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { + type: NOTE_TYPES.WATCH_HISTORY_CHUNK, + label: "Watch history snapshot", + kind: WATCH_HISTORY_KIND, + identifierTag: { + name: "d", + value: WATCH_HISTORY_LIST_IDENTIFIER, + }, + encryptionTag: { name: "encrypted", value: "nip04" }, + snapshotTagName: "snapshot", + chunkTagName: "chunk", + headTag: { name: "head", value: "1" }, + headTagIndex: 2, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: + "Encrypted JSON payload containing chunked watch history entries.", + }, + }, + [NOTE_TYPES.SUBSCRIPTION_LIST]: { + type: NOTE_TYPES.SUBSCRIPTION_LIST, + label: "Subscription list", + kind: 30002, + identifierTag: { + name: "d", + value: SUBSCRIPTION_LIST_IDENTIFIER, + }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: "Encrypted JSON: { subPubkeys: string[] }.", + }, + }, + [NOTE_TYPES.USER_BLOCK_LIST]: { + type: NOTE_TYPES.USER_BLOCK_LIST, + label: "User block list", + kind: 30002, + identifierTag: { + name: "d", + value: BLOCK_LIST_IDENTIFIER, + }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: "Encrypted JSON: { blockedPubkeys: string[] }.", + }, + }, + [NOTE_TYPES.ADMIN_MODERATION_LIST]: { + type: NOTE_TYPES.ADMIN_MODERATION_LIST, + label: "Admin moderation list", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, + [NOTE_TYPES.ADMIN_BLACKLIST]: { + type: NOTE_TYPES.ADMIN_BLACKLIST, + label: "Admin blacklist", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, + [NOTE_TYPES.ADMIN_WHITELIST]: { + type: NOTE_TYPES.ADMIN_WHITELIST, + label: "Admin whitelist", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, +}; + +let schemaOverrides = {}; + +function clone(value) { + if (Array.isArray(value)) { + return value.map(clone); + } + if (value && typeof value === "object") { + const result = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = clone(nested); + } + return result; + } + return value; +} + +function mergeDeep(base, override) { + const result = clone(base); + if (!override || typeof override !== "object") { + return result; + } + for (const [key, value] of Object.entries(override)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = mergeDeep(base?.[key] ?? {}, value); + } else { + result[key] = clone(value); + } + } + return result; +} + +function resolveOverride(type) { + const local = schemaOverrides?.[type]; + if (local) { + return local; + } + + if (typeof window !== "undefined") { + const runtimeOverrides = window.BitVidNostrEventOverrides; + if (runtimeOverrides && runtimeOverrides[type]) { + return runtimeOverrides[type]; + } + } + return null; +} + +export function setNostrEventSchemaOverrides(overrides = {}) { + if (overrides && typeof overrides === "object") { + schemaOverrides = overrides; + } else { + schemaOverrides = {}; + } + + if (typeof window !== "undefined") { + window.BitVidNostrEventOverrides = schemaOverrides; + } +} + +export function getNostrEventSchema(type) { + const base = BASE_SCHEMAS[type]; + if (!base) { + return null; + } + const override = resolveOverride(type); + return mergeDeep(base, override); +} + +export function getAllNostrEventSchemas() { + const entries = {}; + for (const type of Object.keys(BASE_SCHEMAS)) { + entries[type] = getNostrEventSchema(type); + } + return entries; +} + +function appendSchemaTags(tags, schema) { + if (!Array.isArray(schema?.appendTags)) { + return tags; + } + schema.appendTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + return tags; +} + +export function buildVideoPostEvent({ + pubkey, + created_at, + dTagValue, + content, + additionalTags = [], +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST); + const tags = []; + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + if (schema?.identifierTag?.name && dTagValue) { + tags.push([schema.identifierTag.name, dTagValue]); + } + appendSchemaTags(tags, schema); + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + return { + kind: schema?.kind ?? 30078, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : JSON.stringify(content ?? {}), + }; +} + +export function buildVideoMirrorEvent({ + pubkey, + created_at, + tags = [], + content = "", +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR); + const combinedTags = []; + appendSchemaTags(combinedTags, schema); + if (Array.isArray(tags)) { + tags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + return { + kind: schema?.kind ?? 1063, + pubkey, + created_at, + tags: combinedTags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildViewEvent({ + pubkey, + created_at, + pointerValue, + pointerTag, + dedupeTag, + includeSessionTag = false, + additionalTags = [], + content = "", +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT); + const tags = []; + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + + const pointerTagName = schema?.pointerTagName || "video"; + if (pointerValue) { + tags.push([pointerTagName, pointerValue]); + } + if (Array.isArray(pointerTag) && pointerTag.length >= 2) { + tags.push(pointerTag.map((value) => (typeof value === "string" ? value : String(value)))); + } + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + if (dedupeTag) { + const identifierName = schema?.identifierTag?.name || "d"; + const hasDedupe = tags.some( + (tag) => tag[0] === identifierName && tag[1] === dedupeTag + ); + if (!hasDedupe) { + tags.push([identifierName, dedupeTag]); + } + } + if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) { + const hasSession = tags.some( + (tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value + ); + if (!hasSession) { + tags.push([schema.sessionTag.name, schema.sessionTag.value]); + } + } + appendSchemaTags(tags, schema); + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildWatchHistoryChunkEvent({ + pubkey, + created_at, + chunkIdentifier, + snapshotId, + chunkIndex, + totalChunks, + pointerTags = [], + chunkAddresses = [], + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = chunkIdentifier || schema?.identifierTag?.value; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) { + tags.push([schema.encryptionTag.name, schema.encryptionTag.value]); + } + const snapshotTagName = schema?.snapshotTagName; + if (snapshotTagName && snapshotId) { + tags.push([snapshotTagName, snapshotId]); + } + const chunkTagName = schema?.chunkTagName; + if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") { + tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]); + } + pointerTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + + if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) { + const insertionIndex = Number.isInteger(schema?.headTagIndex) + ? Math.max(0, Math.min(tags.length, schema.headTagIndex)) + : Math.min(tags.length, 2); + tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]); + chunkAddresses.forEach((address) => { + if (typeof address === "string" && address) { + tags.push(["a", address]); + } + }); + } + + appendSchemaTags(tags, schema); + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildSubscriptionListEvent({ + pubkey, + created_at, + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30002, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildBlockListEvent({ + pubkey, + created_at, + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30002, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +function resolveAdminNoteType(listKey) { + switch (listKey) { + case "moderation": + case "editors": + return NOTE_TYPES.ADMIN_MODERATION_LIST; + case "whitelist": + return NOTE_TYPES.ADMIN_WHITELIST; + case "blacklist": + return NOTE_TYPES.ADMIN_BLACKLIST; + default: + return null; + } +} + +export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) { + const schema = getNostrEventSchema(resolveAdminNoteType(listKey)); + if (!schema) { + if (isDevMode) { + console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`); + } + return { + kind: 30000, + pubkey, + created_at, + tags: [], + content: "", + }; + } + + const tags = []; + if (schema?.identifierTag?.name && schema?.identifierTag?.value) { + tags.push([schema.identifierTag.name, schema.identifierTag.value]); + } + const participantTagName = schema?.participantTagName || "p"; + hexPubkeys.forEach((hex) => { + if (typeof hex === "string" && hex.trim()) { + tags.push([participantTagName, hex.trim()]); + } + }); + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30000, + pubkey, + created_at, + tags, + content: "", + }; +} + +if (typeof window !== "undefined") { + window.BitVidNostrEvents = { + NOTE_TYPES, + getSchema: getNostrEventSchema, + getAllSchemas: getAllNostrEventSchemas, + setOverrides: setNostrEventSchemaOverrides, + }; +} diff --git a/js/subscriptions.js b/js/subscriptions.js index 95392d7f..a11f5b6e 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -5,6 +5,10 @@ import { } from "./nostr.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; +import { + buildSubscriptionListEvent, + SUBSCRIPTION_LIST_IDENTIFIER, +} from "./nostrEventSchemas.js"; function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -58,7 +62,7 @@ class SubscriptionsManager { const filter = { kinds: [30002], authors: [userPubkey], - "#d": ["subscriptions"], + "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1, }; @@ -168,13 +172,11 @@ class SubscriptionsManager { throw err; } - const evt = { - kind: 30002, + const evt = buildSubscriptionListEvent({ pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), - tags: [["d", "subscriptions"]], content: cipherText, - }; + }); try { const signedEvent = await window.nostr.signEvent(evt); diff --git a/js/userBlocks.js b/js/userBlocks.js index 2845d203..56aaac8f 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,7 +1,6 @@ // js/userBlocks.js import { nostrClient } from "./nostr.js"; - -const BLOCK_LIST_IDENTIFIER = "user-blocks"; +import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; function normalizeHex(pubkey) { if (typeof pubkey !== "string") { @@ -250,13 +249,11 @@ class UserBlockListManager { throw err; } - const event = { - kind: 30002, + const event = buildBlockListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), - tags: [["d", BLOCK_LIST_IDENTIFIER]], content: cipherText, - }; + }); const signedEvent = await window.nostr.signEvent(event); From 284bab6cff6494a8063bd85b0ccaea884faea2ea Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:42:50 -0400 Subject: [PATCH 18/82] Align sidebar loading indicators and history heading --- css/style.css | 29 +++++++++++++++++++++++------ js/app.js | 8 ++++---- js/historyView.js | 13 +++++-------- js/sidebarLoading.js | 33 +++++++++++++++++++++++++++++++++ js/subscriptions.js | 3 +++ views/history.html | 31 +++++++++++++++---------------- views/most-recent-videos.html | 7 ++++++- views/subscriptions.html | 9 ++++++++- 8 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 js/sidebarLoading.js diff --git a/css/style.css b/css/style.css index 3f61eb93..456ca6c6 100644 --- a/css/style.css +++ b/css/style.css @@ -1144,12 +1144,6 @@ button:not(.icon-button):focus { display: flex; } -.watch-history-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - .status-spinner--inline { animation: spin 1s linear infinite; border: 0.125rem solid rgba(96, 165, 250, 0.35); @@ -1160,6 +1154,29 @@ button:not(.icon-button):focus { width: 1rem; } +.sidebar-loading-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding: 3rem 0; + width: 100%; + grid-column: 1 / -1; +} + +.sidebar-loading-indicator { + display: inline-flex; + align-items: center; + gap: 0.75rem; + color: #6b7280; + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.01em; +} + +.sidebar-loading-text { + white-space: nowrap; +} + .status-banner { align-items: center; background-color: rgba(31, 41, 55, 0.9); diff --git a/js/app.js b/js/app.js index 8fa09e81..7d65cf0c 100644 --- a/js/app.js +++ b/js/app.js @@ -42,6 +42,7 @@ import { } from "./storage/r2-s3.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; +import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -7534,10 +7535,9 @@ class bitvidApp { if (!this.videoSubscription) { if (this.videoList) { this.lastRenderedVideoSignature = null; - this.videoList.innerHTML = ` -

- Loading videos as they arrive... -

`; + this.videoList.innerHTML = getSidebarLoadingMarkup( + "Fetching recent videos…" + ); } // Create a new subscription diff --git a/js/historyView.js b/js/historyView.js index 4c306b39..3c5a2b2a 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -4,6 +4,7 @@ import { nostrClient } from "./nostr.js"; import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; import { subscriptions } from "./subscriptions.js"; import { accessControl } from "./accessControl.js"; +import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; @@ -672,14 +673,10 @@ export function createWatchHistoryRenderer(config = {}) { const nextMessage = typeof message === "string" ? message.trim() : ""; if (nextMessage) { - if (nextMessage === WATCH_HISTORY_LOADING_STATUS) { - statusEl.innerHTML = - '' + - WATCH_HISTORY_LOADING_STATUS + - ""; - } else { - statusEl.textContent = nextMessage; - } + const showSpinner = nextMessage === WATCH_HISTORY_LOADING_STATUS; + statusEl.innerHTML = getSidebarLoadingMarkup(nextMessage, { + showSpinner, + }); statusEl.classList.remove("hidden"); } else { statusEl.textContent = ""; diff --git a/js/sidebarLoading.js b/js/sidebarLoading.js new file mode 100644 index 00000000..4e88280d --- /dev/null +++ b/js/sidebarLoading.js @@ -0,0 +1,33 @@ +// js/sidebarLoading.js + +function escapeHtml(value) { + if (typeof value !== "string") { + return ""; + } + + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function getSidebarLoadingMarkup(message = "", options = {}) { + const trimmed = typeof message === "string" ? message.trim() : ""; + const copy = trimmed || "Fetching data…"; + const safeCopy = escapeHtml(copy); + const showSpinner = options?.showSpinner !== false; + const spinnerMarkup = showSpinner + ? '' + : ""; + + return ` + + `; +} diff --git a/js/subscriptions.js b/js/subscriptions.js index a11f5b6e..06d0519d 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -9,6 +9,7 @@ import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, } from "./nostrEventSchemas.js"; +import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -226,6 +227,8 @@ class SubscriptionsManager { return; } + container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…"); + // Gather all videos const videos = await this.fetchSubscribedVideos(channelHexes); this.renderSameGridStyle(videos, containerId); diff --git a/views/history.html b/views/history.html index f5d08587..a6ce1557 100644 --- a/views/history.html +++ b/views/history.html @@ -1,21 +1,20 @@
-
-

Watch History

-
-
- - +

Watch History

+
+
+ +
diff --git a/views/subscriptions.html b/views/subscriptions.html index 309018a0..4f9a6bc9 100644 --- a/views/subscriptions.html +++ b/views/subscriptions.html @@ -9,5 +9,12 @@ gap: 2rem; padding: 1.5rem 0; " - >
+ > + + From fa583f068535530d4ac0fe5369384b874d4ed70e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:49:43 -0400 Subject: [PATCH 19/82] Show spinner while recent videos load --- js/app.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/js/app.js b/js/app.js index 7d65cf0c..e2226a4d 100644 --- a/js/app.js +++ b/js/app.js @@ -7531,14 +7531,17 @@ class bitvidApp { this.videoSubscription = null; } + if (this.videoList) { + if (this._lastRenderedVideoListElement !== this.videoList) { + this.lastRenderedVideoSignature = null; + } + this.videoList.innerHTML = getSidebarLoadingMarkup( + "Fetching recent videos…" + ); + } + // The rest of your existing logic: if (!this.videoSubscription) { - if (this.videoList) { - this.lastRenderedVideoSignature = null; - this.videoList.innerHTML = getSidebarLoadingMarkup( - "Fetching recent videos…" - ); - } // Create a new subscription this.videoSubscription = nostrClient.subscribeVideos(() => { From 9e509bb7824d13506c784d7ba77b48adc69fb03f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:53:00 -0400 Subject: [PATCH 20/82] update --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1c211c1e..93930038 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,4 +81,13 @@ Document the run in PR descriptions so QA can cross-reference results. * **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint. * **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA. +## Next + +Please read these documents next. + +docs/nostr-event-schemas.md +js/nostrEventSchemas.js + +And if you need to create new nostr kinds please keep the logic centralized there in nostrEventSchemas.js and the nostr-event-schemas.md up to date. + **End of AGENTS.md** From 9f2e06744c11ae2ef8b8c458ecee265faa50f18a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:13:11 -0400 Subject: [PATCH 21/82] Add relay list management for profile relays --- docs/nostr-event-schemas.md | 5 + js/app.js | 224 +++++++++++++-- js/nostr.js | 71 ++++- js/nostrEventSchemas.js | 84 ++++++ js/relayManager.js | 533 ++++++++++++++++++++++++++++++++++++ 5 files changed, 895 insertions(+), 22 deletions(-) create mode 100644 js/relayManager.js diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 7b295cf5..28fbcad5 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -37,12 +37,17 @@ const event = buildVideoPostEvent({ In the browser you can call the same helpers from DevTools via `window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`. +When managing relay metadata, use `buildRelayListEvent` so the resulting +replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the +read/write split. + ## Event catalogue | Note | Kind (default) | Tags | Content format | | --- | --- | --- | --- | | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | +| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', ]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, `['head','1']` + `['a',
]` pointers on the first chunk | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | diff --git a/js/app.js b/js/app.js index e2226a4d..31b3e0f0 100644 --- a/js/app.js +++ b/js/app.js @@ -18,6 +18,7 @@ import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; +import { relayManager } from "./relayManager.js"; import { loadR2Settings, saveR2Settings, @@ -4289,7 +4290,7 @@ class bitvidApp { if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) { this.profileAddRelayBtn.dataset.bound = "true"; this.profileAddRelayBtn.addEventListener("click", () => { - this.showSuccess("Relay management coming soon."); + this.handleAddRelay(); }); } if ( @@ -4298,7 +4299,19 @@ class bitvidApp { ) { this.profileRestoreRelaysBtn.dataset.bound = "true"; this.profileRestoreRelaysBtn.addEventListener("click", () => { - this.showSuccess("Relay management coming soon."); + this.handleRestoreRelays(); + }); + } + if ( + this.profileRelayInput && + this.profileRelayInput.dataset.bound !== "true" + ) { + this.profileRelayInput.dataset.bound = "true"; + this.profileRelayInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.handleAddRelay(); + } }); } @@ -4647,21 +4660,40 @@ class bitvidApp { this.lastFocusedBeforeProfileModal = null; } - populateProfileRelays(relayUrls) { + populateProfileRelays(relayEntries = null) { if (!this.profileRelayList) { return; } - const rawRelays = - Array.isArray(relayUrls) - ? relayUrls - : Array.isArray(nostrClient.relays) - ? nostrClient.relays - : Array.from(nostrClient.relays || []); + const sourceEntries = Array.isArray(relayEntries) + ? relayEntries + : relayManager.getEntries(); - const relays = rawRelays - .map((relay) => (typeof relay === "string" ? relay.trim() : "")) - .filter((relay) => relay.length > 0); + const relays = sourceEntries + .map((entry) => { + if (typeof entry === "string") { + const trimmed = entry.trim(); + return trimmed ? { url: trimmed, mode: "both" } : null; + } + if (entry && typeof entry === "object") { + const url = + typeof entry.url === "string" ? entry.url.trim() : ""; + if (!url) { + return null; + } + const mode = typeof entry.mode === "string" ? entry.mode : "both"; + const normalizedMode = + mode === "read" || mode === "write" ? mode : "both"; + return { + url, + mode: normalizedMode, + read: entry.read !== false, + write: entry.write !== false, + }; + } + return null; + }) + .filter((entry) => entry && typeof entry.url === "string"); this.profileRelayList.innerHTML = ""; @@ -4674,7 +4706,7 @@ class bitvidApp { return; } - relays.forEach((relayUrl) => { + relays.forEach((entry) => { const item = document.createElement("li"); item.className = "flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3"; @@ -4684,11 +4716,17 @@ class bitvidApp { const urlEl = document.createElement("p"); urlEl.className = "text-sm font-medium text-gray-100 break-all"; - urlEl.textContent = relayUrl; + urlEl.textContent = entry.url; const statusEl = document.createElement("p"); statusEl.className = "mt-1 text-xs text-gray-400"; - statusEl.textContent = "Active"; + let modeLabel = "Read & write"; + if (entry.mode === "read") { + modeLabel = "Read only"; + } else if (entry.mode === "write") { + modeLabel = "Write only"; + } + statusEl.textContent = modeLabel; info.appendChild(urlEl); info.appendChild(statusEl); @@ -4700,9 +4738,10 @@ class bitvidApp { editBtn.type = "button"; editBtn.className = "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; - editBtn.textContent = "Edit"; + editBtn.textContent = "Change mode"; + editBtn.title = "Cycle between read-only, write-only, or read/write modes."; editBtn.addEventListener("click", () => { - this.showSuccess("Relay management coming soon."); + this.handleRelayModeToggle(entry.url); }); const removeBtn = document.createElement("button"); @@ -4711,7 +4750,7 @@ class bitvidApp { "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; removeBtn.textContent = "Remove"; removeBtn.addEventListener("click", () => { - this.showSuccess("Relay management coming soon."); + this.handleRemoveRelay(entry.url); }); actions.appendChild(editBtn); @@ -4724,6 +4763,144 @@ class bitvidApp { }); } + async handleRelayOperation(operation, { + successMessage = "Relay preferences updated.", + skipPublishIfUnchanged = true, + unchangedMessage = null, + } = {}) { + if (!this.pubkey) { + this.showError("Please login to manage your relays."); + return; + } + + if (typeof operation !== "function") { + return; + } + + const previous = relayManager.snapshot(); + let result; + try { + result = operation(); + } catch (error) { + const message = + error && typeof error.message === "string" && error.message.trim() + ? error.message.trim() + : "Failed to update relay preferences."; + this.showError(message); + return; + } + + const changed = !!result?.changed; + if (!changed && skipPublishIfUnchanged) { + if (result?.reason === "duplicate") { + this.showSuccess("Relay is already configured."); + } else if (typeof unchangedMessage === "string" && unchangedMessage) { + this.showSuccess(unchangedMessage); + } + this.populateProfileRelays(); + return; + } + + this.populateProfileRelays(); + + try { + const publishResult = await relayManager.publishRelayList(this.pubkey); + if (!publishResult?.ok) { + throw new Error("No relays accepted the update."); + } + if (successMessage) { + this.showSuccess(successMessage); + } + } catch (error) { + relayManager.setEntries(previous, { allowEmpty: false }); + this.populateProfileRelays(); + const message = + error && typeof error.message === "string" && error.message.trim() + ? error.message.trim() + : "Failed to publish relay configuration. Please try again."; + this.showError(message); + } + } + + async handleAddRelay() { + if (!this.pubkey) { + this.showError("Please login to manage your relays."); + return; + } + + const rawValue = + typeof this.profileRelayInput?.value === "string" + ? this.profileRelayInput.value + : ""; + const trimmed = rawValue.trim(); + if (!trimmed) { + this.showError("Enter a relay URL to add."); + return; + } + + await this.handleRelayOperation( + () => relayManager.addRelay(trimmed), + { + successMessage: "Relay saved.", + unchangedMessage: "Relay is already configured.", + } + ); + + if (this.profileRelayInput) { + this.profileRelayInput.value = ""; + } + } + + async handleRestoreRelays() { + if (!this.pubkey) { + this.showError("Please login to manage your relays."); + return; + } + + const confirmed = window.confirm( + "Restore the recommended relay defaults?" + ); + if (!confirmed) { + return; + } + + await this.handleRelayOperation( + () => relayManager.restoreDefaults(), + { + successMessage: "Relay defaults restored.", + unchangedMessage: "Relay defaults are already in use.", + } + ); + } + + async handleRelayModeToggle(url) { + if (!url) { + return; + } + await this.handleRelayOperation( + () => relayManager.cycleRelayMode(url), + { successMessage: "Relay mode updated." } + ); + } + + async handleRemoveRelay(url) { + if (!url) { + return; + } + + const confirmed = window.confirm( + `Remove ${url} from your relay list?` + ); + if (!confirmed) { + return; + } + + await this.handleRelayOperation( + () => relayManager.removeRelay(url), + { successMessage: "Relay removed." } + ); + } + populateBlockedList(blocked = null) { if (!this.profileBlockedList || !this.profileBlockedEmpty) { return; @@ -6402,6 +6579,14 @@ class bitvidApp { this.populateBlockedList(); + try { + await relayManager.loadRelayList(this.pubkey); + } catch (error) { + console.warn("Failed to load relay list:", error); + } + + this.populateProfileRelays(); + if (this.loginButton) { this.loginButton.classList.add("hidden"); this.loginButton.setAttribute("hidden", ""); @@ -6581,6 +6766,9 @@ class bitvidApp { userBlocks.reset(); this.populateBlockedList(); + relayManager.reset(); + this.populateProfileRelays(); + // Show the login button again if (this.loginButton) { this.loginButton.classList.remove("hidden"); diff --git a/js/nostr.js b/js/nostr.js index 95da8ecc..ad7179c8 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -24,15 +24,18 @@ import { } from "./nostrEventSchemas.js"; /** - * The usual relays + * The default relay set BitVid bootstraps with before loading a user's + * preferences. */ -const RELAY_URLS = [ +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"; @@ -140,6 +143,35 @@ function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request ti }); } +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 ""; @@ -1098,7 +1130,9 @@ class NostrClient { constructor() { this.pool = null; this.pubkey = null; - this.relays = RELAY_URLS; + 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(); @@ -1215,6 +1249,35 @@ class NostrClient { 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. */ diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 360754d1..b8fbb4de 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -8,6 +8,7 @@ import { export const NOTE_TYPES = Object.freeze({ VIDEO_POST: "videoPost", VIDEO_MIRROR: "videoMirror", + RELAY_LIST: "relayList", VIEW_EVENT: "viewEvent", WATCH_HISTORY_CHUNK: "watchHistoryChunk", SUBSCRIPTION_LIST: "subscriptionList", @@ -66,6 +67,16 @@ const BASE_SCHEMAS = { description: "Optional alt text carried alongside hosted URL metadata.", }, }, + [NOTE_TYPES.RELAY_LIST]: { + type: NOTE_TYPES.RELAY_LIST, + label: "Relay list metadata", + kind: 10002, + relayTagName: "r", + readMarker: "read", + writeMarker: "write", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, [NOTE_TYPES.VIEW_EVENT]: { type: NOTE_TYPES.VIEW_EVENT, label: "View counter", @@ -311,6 +322,79 @@ export function buildVideoMirrorEvent({ }; } +export function buildRelayListEvent({ + pubkey, + created_at, + relays = [], + additionalTags = [], +}) { + const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST); + const tags = []; + const relayTagName = schema?.relayTagName || "r"; + const readMarker = schema?.readMarker || "read"; + const writeMarker = schema?.writeMarker || "write"; + + if (Array.isArray(relays)) { + relays.forEach((entry) => { + let url = ""; + let mode = ""; + if (typeof entry === "string") { + url = entry; + } else if (entry && typeof entry === "object") { + if (typeof entry.url === "string") { + url = entry.url; + } + if (typeof entry.mode === "string") { + mode = entry.mode; + } else if (typeof entry.marker === "string") { + mode = entry.marker; + } else if (entry.read === true && entry.write === false) { + mode = "read"; + } else if (entry.write === true && entry.read === false) { + mode = "write"; + } + } else if (Array.isArray(entry) && entry.length >= 1) { + url = typeof entry[0] === "string" ? entry[0] : String(entry[0]); + if (entry.length > 1 && typeof entry[1] === "string") { + mode = entry[1]; + } + } + + const normalizedUrl = typeof url === "string" ? url.trim() : ""; + if (!normalizedUrl) { + return; + } + + const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : ""; + if (normalizedMode === "read") { + tags.push([relayTagName, normalizedUrl, readMarker]); + } else if (normalizedMode === "write") { + tags.push([relayTagName, normalizedUrl, writeMarker]); + } else { + tags.push([relayTagName, normalizedUrl]); + } + }); + } + + appendSchemaTags(tags, schema); + + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + return { + kind: schema?.kind ?? 10002, + pubkey, + created_at, + tags, + content: "", + }; +} + export function buildViewEvent({ pubkey, created_at, diff --git a/js/relayManager.js b/js/relayManager.js new file mode 100644 index 00000000..43e93596 --- /dev/null +++ b/js/relayManager.js @@ -0,0 +1,533 @@ +import { isDevMode } from "./config.js"; +import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js"; +import { buildRelayListEvent } from "./nostrEventSchemas.js"; + +const MODE_SEQUENCE = ["both", "read", "write"]; + +function normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + if (!/^[0-9a-f]{64}$/i.test(trimmed)) { + return null; + } + return trimmed.toLowerCase(); +} + +function normalizeRelayUrl(input) { + if (typeof input !== "string") { + return null; + } + + let candidate = input.trim(); + if (!candidate) { + return null; + } + + if (!/^[a-z]+:\/\//i.test(candidate)) { + candidate = `wss://${candidate}`; + } + + let parsed; + try { + parsed = new URL(candidate); + } catch (error) { + return null; + } + + if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") { + return null; + } + + const hostname = parsed.hostname.toLowerCase(); + const port = parsed.port ? `:${parsed.port}` : ""; + let pathname = parsed.pathname || ""; + if (pathname !== "/") { + pathname = pathname.replace(/\/+$/, ""); + } + if (pathname === "/") { + pathname = ""; + } + const search = parsed.search || ""; + const hash = parsed.hash || ""; + + return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`; +} + +function deriveMode(marker) { + if (typeof marker !== "string") { + return "both"; + } + const normalized = marker.trim().toLowerCase(); + if (normalized === "read") { + return "read"; + } + if (normalized === "write") { + return "write"; + } + return "both"; +} + +function createEntry(url, mode = "both") { + const normalizedMode = deriveMode(mode); + return { + url, + mode: normalizedMode, + read: normalizedMode !== "write", + write: normalizedMode !== "read", + }; +} + +function cloneEntry(entry) { + return { + url: entry.url, + mode: entry.mode, + read: !!entry.read, + write: !!entry.write, + }; +} + +function serializeEntries(entries) { + return JSON.stringify( + entries.map((entry) => ({ url: entry.url, mode: entry.mode })) + ); +} + +function parseRelayTags(tags) { + if (!Array.isArray(tags)) { + return []; + } + + const seen = new Map(); + const order = []; + + tags.forEach((tag) => { + if (!Array.isArray(tag) || tag.length < 2) { + return; + } + if (tag[0] !== "r") { + return; + } + const normalizedUrl = normalizeRelayUrl(tag[1]); + if (!normalizedUrl) { + return; + } + if (!seen.has(normalizedUrl)) { + seen.set(normalizedUrl, { read: false, write: false }); + order.push(normalizedUrl); + } + const record = seen.get(normalizedUrl); + const mode = deriveMode(tag[2]); + if (mode === "read") { + record.read = true; + } else if (mode === "write") { + record.write = true; + } else { + record.read = true; + record.write = true; + } + }); + + return order.map((url) => { + const record = seen.get(url) || { read: true, write: true }; + if (record.read && record.write) { + return createEntry(url, "both"); + } + if (record.read) { + return createEntry(url, "read"); + } + if (record.write) { + return createEntry(url, "write"); + } + return createEntry(url, "both"); + }); +} + +function resolveEntryInput(entry) { + if (typeof entry === "string") { + return { url: entry, mode: "both" }; + } + if (!entry || typeof entry !== "object") { + return null; + } + const url = typeof entry.url === "string" ? entry.url : ""; + let mode = entry.mode || entry.marker; + if (!mode) { + if (entry.read === true && entry.write === false) { + mode = "read"; + } else if (entry.write === true && entry.read === false) { + mode = "write"; + } + } + return { url, mode: deriveMode(mode) }; +} + +class RelayPreferencesManager { + constructor() { + this.entries = []; + this.entryIndex = new Map(); + this.lastEvent = null; + this.loadedPubkey = null; + this.lastLoadSource = "default"; + this.defaultEntries = DEFAULT_RELAY_URLS.map((url) => { + const normalized = normalizeRelayUrl(url) || url; + return createEntry(normalized, "both"); + }); + this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true }); + } + + snapshot() { + return this.getEntries(); + } + + getEntries() { + return this.entries.map((entry) => cloneEntry(entry)); + } + + getAllRelayUrls() { + return this.entries.map((entry) => entry.url); + } + + getReadRelayUrls() { + return this.entries.filter((entry) => entry.read).map((entry) => entry.url); + } + + getWriteRelayUrls() { + return this.entries.filter((entry) => entry.write).map((entry) => entry.url); + } + + syncClient() { + if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") { + nostrClient.applyRelayPreferences({ + all: this.getAllRelayUrls(), + read: this.getReadRelayUrls(), + write: this.getWriteRelayUrls(), + }); + } else if (nostrClient) { + nostrClient.relays = this.getAllRelayUrls(); + } + } + + setEntries(entries, { allowEmpty = false, updateClient = true } = {}) { + const nextEntries = []; + const indexMap = new Map(); + + if (Array.isArray(entries)) { + entries.forEach((item) => { + const resolved = resolveEntryInput(item); + if (!resolved) { + return; + } + const normalizedUrl = normalizeRelayUrl(resolved.url); + if (!normalizedUrl || indexMap.has(normalizedUrl)) { + return; + } + const normalizedEntry = createEntry(normalizedUrl, resolved.mode); + indexMap.set(normalizedUrl, nextEntries.length); + nextEntries.push(normalizedEntry); + }); + } + + if (!nextEntries.length && !allowEmpty) { + this.entries = this.defaultEntries.map((entry) => cloneEntry(entry)); + this.entryIndex = new Map( + this.entries.map((entry, idx) => [entry.url, idx]) + ); + if (updateClient) { + this.syncClient(); + } + return this.getEntries(); + } + + this.entries = nextEntries.map((entry) => cloneEntry(entry)); + this.entryIndex = new Map( + this.entries.map((entry, idx) => [entry.url, idx]) + ); + + if (!this.entries.length && !allowEmpty) { + this.entries = this.defaultEntries.map((entry) => cloneEntry(entry)); + this.entryIndex = new Map( + this.entries.map((entry, idx) => [entry.url, idx]) + ); + } + + if (updateClient) { + this.syncClient(); + } + + return this.getEntries(); + } + + addRelay(url, mode = "both") { + const normalizedUrl = normalizeRelayUrl(url); + if (!normalizedUrl) { + const error = new Error("Enter a valid WSS relay URL."); + error.code = "invalid"; + throw error; + } + if (this.entryIndex.has(normalizedUrl)) { + return { + changed: false, + reason: "duplicate", + entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]), + }; + } + + const nextEntries = this.getEntries(); + nextEntries.push({ url: normalizedUrl, mode }); + this.setEntries(nextEntries, { allowEmpty: false }); + + return { + changed: true, + entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]), + }; + } + + updateRelayMode(url, mode = "both") { + const normalizedUrl = normalizeRelayUrl(url); + if (!normalizedUrl) { + const error = new Error("Enter a valid relay URL."); + error.code = "invalid"; + throw error; + } + if (!this.entryIndex.has(normalizedUrl)) { + return { changed: false, reason: "missing" }; + } + + const normalizedMode = deriveMode(mode); + const index = this.entryIndex.get(normalizedUrl); + const current = this.entries[index]; + if (current.mode === normalizedMode) { + return { changed: false, entry: cloneEntry(current) }; + } + + const nextEntries = this.getEntries(); + nextEntries[index].mode = normalizedMode; + this.setEntries(nextEntries, { allowEmpty: false }); + + return { + changed: true, + entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]), + }; + } + + cycleRelayMode(url) { + const normalizedUrl = normalizeRelayUrl(url); + if (!normalizedUrl) { + const error = new Error("Enter a valid relay URL."); + error.code = "invalid"; + throw error; + } + if (!this.entryIndex.has(normalizedUrl)) { + const error = new Error("Relay not found."); + error.code = "missing"; + throw error; + } + const index = this.entryIndex.get(normalizedUrl); + const currentMode = this.entries[index].mode || "both"; + const currentIdx = MODE_SEQUENCE.indexOf(currentMode); + const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length]; + return this.updateRelayMode(normalizedUrl, nextMode); + } + + removeRelay(url) { + const normalizedUrl = normalizeRelayUrl(url); + if (!normalizedUrl) { + const error = new Error("Enter a valid relay URL to remove."); + error.code = "invalid"; + throw error; + } + if (!this.entryIndex.has(normalizedUrl)) { + return { changed: false, reason: "missing" }; + } + if (this.entries.length <= 1) { + const error = new Error("At least one relay is required."); + error.code = "minimum"; + throw error; + } + + const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl); + this.setEntries(nextEntries, { allowEmpty: false }); + return { changed: true }; + } + + restoreDefaults() { + const before = serializeEntries(this.entries); + this.setEntries(this.defaultEntries, { allowEmpty: false }); + const after = serializeEntries(this.entries); + return { changed: before !== after }; + } + + reset() { + this.lastEvent = null; + this.loadedPubkey = null; + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + } + + getPublishTargets(customTargets = null) { + const targets = new Set(); + const list = + Array.isArray(customTargets) && customTargets.length + ? customTargets + : this.getAllRelayUrls(); + + list.forEach((candidate) => { + const normalized = normalizeRelayUrl(candidate); + if (normalized) { + targets.add(normalized); + } + }); + + DEFAULT_RELAY_URLS.forEach((url) => { + const normalized = normalizeRelayUrl(url) || url; + targets.add(normalized); + }); + + return Array.from(targets); + } + + async loadRelayList(pubkey) { + const normalizedPubkey = normalizeHexPubkey(pubkey); + this.loadedPubkey = normalizedPubkey; + + if (!normalizedPubkey) { + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: false, reason: "invalid-pubkey" }; + } + + if (!nostrClient?.pool) { + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: false, reason: "nostr-uninitialized" }; + } + + const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 }; + const targetRelays = this.getPublishTargets(); + const events = []; + + await Promise.all( + targetRelays.map(async (relayUrl) => { + try { + const result = await nostrClient.pool.list([relayUrl], [filter]); + if (Array.isArray(result) && result.length) { + events.push(...result); + } + } catch (error) { + if (isDevMode) { + console.warn(`[relayManager] Failed to fetch relay list from ${relayUrl}:`, error); + } + } + }) + ); + + if (!events.length) { + this.lastEvent = null; + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: true, source: "default", events: [] }; + } + + events.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); + const newest = events[0]; + this.lastEvent = newest; + + const parsed = parseRelayTags(newest?.tags); + if (parsed.length) { + this.setEntries(parsed, { allowEmpty: false }); + this.lastLoadSource = "event"; + return { ok: true, source: "event", event: newest }; + } + + this.lastLoadSource = "default"; + this.setEntries(this.defaultEntries, { allowEmpty: false }); + return { ok: true, source: "default", event: newest }; + } + + async publishRelayList(pubkey, options = {}) { + const normalizedPubkey = normalizeHexPubkey(pubkey); + if (!normalizedPubkey) { + const error = new Error( + "A valid pubkey is required to publish relay preferences." + ); + error.code = "invalid-pubkey"; + throw error; + } + + if (!window?.nostr?.signEvent) { + const error = new Error( + "A NIP-07 extension is required to publish relay preferences." + ); + error.code = "nostr-extension-missing"; + throw error; + } + + if (!nostrClient?.pool) { + const error = new Error( + "Nostr is not connected yet. Please try again once relays are ready." + ); + error.code = "nostr-uninitialized"; + throw error; + } + + const entries = this.getEntries(); + if (!entries.length) { + const error = new Error("Add at least one relay before publishing."); + error.code = "empty"; + throw error; + } + + const event = buildRelayListEvent({ + pubkey: normalizedPubkey, + created_at: Math.floor(Date.now() / 1000), + relays: entries, + }); + + const signedEvent = await window.nostr.signEvent(event); + const targets = this.getPublishTargets(options?.relayUrls); + + if (!targets.length) { + const error = new Error("No relay targets are available for publishing."); + error.code = "no-targets"; + throw error; + } + + const publishResults = await Promise.allSettled( + targets.map((url) => nostrClient.pool.publish([url], signedEvent)) + ); + + const accepted = []; + const failed = []; + + publishResults.forEach((result, index) => { + const url = targets[index]; + if (result.status === "fulfilled") { + accepted.push(url); + } else { + failed.push({ url, reason: result.reason || null }); + } + }); + + const ok = accepted.length > 0; + if (ok) { + this.lastEvent = signedEvent; + } + + return { ok, event: signedEvent, accepted, failed, targets }; + } + + getLastLoadSource() { + return this.lastLoadSource; + } +} + +export const relayManager = new RelayPreferencesManager(); + +export function normalizeRelayUrlForDisplay(value) { + return normalizeRelayUrl(value); +} From b2ceb197f2860815d56604afdcffd96172d9feed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:25:37 -0400 Subject: [PATCH 22/82] Fix sidebar loading spinners for videos and history --- components/profile-modal.html | 29 ++++++++++++++++++----------- js/app.js | 8 +++++--- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 86b844ba..21d520cf 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -291,17 +291,24 @@ Your recently watched videos appear here so you can jump back into favorites.

-
-
- +
+
+ +
Date: Wed, 1 Oct 2025 14:59:04 -0400 Subject: [PATCH 23/82] Add dedicated watch history index events --- config/instance-config.js | 10 +- docs/nostr-event-schemas.md | 17 +- js/config.js | 2 + js/nostr.js | 517 ++++++++++++++++++++++++++++++++--- js/nostrEventSchemas.js | 87 +++++- tests/watch-history.test.mjs | 198 ++++++++++++-- 6 files changed, 769 insertions(+), 62 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 8ae587ac..a439923c 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -63,7 +63,15 @@ export const WATCH_HISTORY_KIND = 30078; * BitVid deployments that share relays, but keep it stable once clients begin * syncing history. */ -export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history"; +export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history:v2:index"; + +/** + * Legacy identifiers that BitVid clients should continue honoring when + * fetching historical watch-history snapshots. + */ +export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([ + "watch-history", +]); /** * Maximum number of watch-history entries to retain per user. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 28fbcad5..a5c88dd9 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -49,7 +49,8 @@ read/write split. | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', ]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message | -| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, `['head','1']` + `['a',
]` pointers on the first chunk | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | +| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | +| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | @@ -59,3 +60,17 @@ read/write split. If you introduce a new Nostr feature, add its schema to `js/nostrEventSchemas.js` so that the catalogue stays complete and so existing builders inherit the same debugging knobs. + +### Watch history identifiers + +Watch-history snapshots now publish a dedicated index event with +`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history:v2:index`). The +index advertises the active snapshot id, total chunk count, and a pointer to +each chunk via `a` tags so clients can fetch the encrypted payloads without +guessing identifiers. + +Every chunk event uses a unique `d` tag such as +`watch-history:v2//`. Legacy snapshots created before the +index event shipped stored the first chunk at `#d watch-history`; new clients +should continue reading that identifier for backward compatibility when +hydrating older histories. diff --git a/js/config.js b/js/config.js index a44c3cd9..0a102695 100644 --- a/js/config.js +++ b/js/config.js @@ -6,6 +6,7 @@ import { DEFAULT_WHITELIST_MODE_ENABLED, 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, @@ -59,6 +60,7 @@ export { ADMIN_WHITELIST_MODE_STORAGE_KEY }; export { WATCH_HISTORY_KIND }; export { WATCH_HISTORY_LIST_IDENTIFIER }; +export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS }; export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; diff --git a/js/nostr.js b/js/nostr.js index ad7179c8..d82503ab 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4,6 +4,7 @@ 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, @@ -20,6 +21,7 @@ import { buildVideoPostEvent, buildVideoMirrorEvent, buildViewEvent, + buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } from "./nostrEventSchemas.js"; @@ -46,6 +48,76 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1"; +const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER = + typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" + ? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase() + : ""; + +const 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 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 deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) { + const prefix = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX || "watch-history"; + const sanitizedPrefix = 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); +} + // To limit error spam let errorLogCount = 0; const MAX_ERROR_LOGS = 100; @@ -1930,9 +2002,7 @@ class NostrClient { const totalChunks = chunkItemsList.length || 1; const chunkIdentifiers = chunkItemsList.map((_, index) => - index === 0 - ? WATCH_HISTORY_LIST_IDENTIFIER - : `watch-history:${snapshotId}:${index}` + deriveWatchHistoryChunkIdentifier(snapshotId, index) ); const chunkAddresses = chunkIdentifiers.map( (identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}` @@ -2002,7 +2072,8 @@ class NostrClient { return tag; }); - const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER; + 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. @@ -2014,7 +2085,6 @@ class NostrClient { chunkIndex: index, totalChunks, pointerTags, - chunkAddresses, content: encryptionResult.ciphertext, }); @@ -2065,6 +2135,50 @@ class NostrClient { }); } + const indexEvent = buildWatchHistoryIndexEvent({ + pubkey: normalizedActor, + created_at: baseTimestamp + totalChunks, + snapshotId, + totalChunks, + chunkAddresses, + }); + + let signedIndexEvent = null; + 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; + } + + let indexPublishResults = []; + let indexSuccess = false; + 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 + ); + } + } else { + overallSuccess = false; + } + if (overallSuccess) { this.cancelWatchHistoryRepublish(normalizedActor); } else if (allowRetry && persistedItems.length) { @@ -2074,23 +2188,91 @@ class NostrClient { const baselineEntry = existingEntry || this.watchHistoryCache.get(normalizedActor) || null; - const headEvent = chunkResults[0]?.event || null; + const pointerEvent = signedIndexEvent || chunkResults[0]?.event || null; const newEntry = this.createWatchHistoryEntry( - headEvent, + 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: headEvent, - events: chunkResults.map((chunk) => chunk.event), + event: pointerEvent, + indexEvent: signedIndexEvent, + events: combinedEvents, chunks: chunkResults, - results: chunkResults.map((chunk) => chunk.results), + results: combinedResults, + indexResults: indexPublishResults, snapshot: snapshotId, items: newEntry.items .map((item) => clonePointerItem(item)) @@ -2819,13 +3001,34 @@ class NostrClient { : WATCH_HISTORY_FETCH_EVENT_LIMIT; const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate)); + const headIdentifierSet = new Set(); + if ( + typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" && + WATCH_HISTORY_LIST_IDENTIFIER.trim() + ) { + headIdentifierSet.add(WATCH_HISTORY_LIST_IDENTIFIER.trim()); + } + if (Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)) { + WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS.forEach((value) => { + if (typeof value === "string" && value.trim()) { + headIdentifierSet.add(value.trim()); + } + }); + } + if (!headIdentifierSet.size && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) { + headIdentifierSet.add(WATCH_HISTORY_LIST_IDENTIFIER); + } + const filter = { kinds: [WATCH_HISTORY_KIND], authors: [actor], - "#d": [WATCH_HISTORY_LIST_IDENTIFIER], limit: fetchLimit, }; + if (headIdentifierSet.size) { + filter["#d"] = Array.from(headIdentifierSet); + } + let fetchedHeadEvents = []; try { const events = await this.pool.list(relayList, [filter]); @@ -2864,6 +3067,7 @@ class NostrClient { const chunkIdentifiers = new Set(); const snapshotIds = new Set(); + const indexSnapshots = new Map(); for (const event of fetchedHeadEvents) { if (!event || typeof event !== "object") { @@ -2871,15 +3075,29 @@ class NostrClient { } 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] === "snapshot" && typeof tag[1] === "string" && tag[1]) { - snapshotIds.add(tag[1]); + 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") { + 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 ( @@ -2889,13 +3107,119 @@ class NostrClient { pubkey === actor && typeof identifier === "string" && identifier && - identifier !== WATCH_HISTORY_LIST_IDENTIFIER + !isIndexIdentifier(identifier) ) { - chunkIdentifiers.add(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") { + try { + const parsed = JSON.parse(event.content); + 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 + ); + } + } + } + + 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); + } + } } const chunkFilters = []; @@ -2981,11 +3305,15 @@ class NostrClient { 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") { @@ -3035,6 +3363,9 @@ class NostrClient { latestCreatedAt: typeof event.created_at === "number" ? event.created_at : 0, version: payload.version || 0, + indexEvent: null, + chunkAddresses: new Set(), + expectedChunkIdentifiers: new Set(), }; bucket.chunks.set(chunkIndex, { @@ -3054,6 +3385,58 @@ class NostrClient { bucket.latestCreatedAt = createdAt; } bucket.version = Math.max(bucket.version, payload.version || 0); + 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(), + }; + + 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); } @@ -3070,7 +3453,7 @@ class NostrClient { ); const seenKeys = new Set(); const combinedItems = []; - let pointerCandidate = null; + let pointerCandidate = bucket.indexEvent || null; for (const index of chunkIndices) { const chunk = bucket.chunks.get(index); @@ -3105,6 +3488,12 @@ class NostrClient { 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(), }; if (!fallbackSnapshot) { @@ -3168,8 +3557,31 @@ class NostrClient { } const chunkPointerItemsForEntry = []; - let headPointerForEntry = null; const chunkPointerKeys = new Set(); + let indexPointerForEntry = null; + let firstChunkPointer = null; + + if (effectiveSnapshot?.pointerEvent) { + const indexAddress = eventToAddressPointer(effectiveSnapshot.pointerEvent); + 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(effectiveSnapshot.pointerEvent); + if (clonedIndex) { + entry.chunkEvents.set(key, clonedIndex); + } + indexPointerForEntry = pointer; + } + } + } + } if (effectiveSnapshot?.snapshotId) { const bucketForEntry = snapshotMap.get(effectiveSnapshot.snapshotId); @@ -3194,7 +3606,7 @@ class NostrClient { continue; } const key = pointerKey(pointer); - if (!key) { + if (!key || chunkPointerKeys.has(key)) { continue; } const clonedChunkEvent = cloneEventForCache(chunk.event); @@ -3202,25 +3614,57 @@ class NostrClient { entry.chunkEvents.set(key, clonedChunkEvent); } chunkPointerKeys.add(key); - if (chunk.payload?.chunkIndex === 0) { - headPointerForEntry = pointer; - } else { - chunkPointerItemsForEntry.push(pointer); + chunkPointerItemsForEntry.push(pointer); + if (chunk.payload?.chunkIndex === 0 && !firstChunkPointer) { + 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: headPointerForEntry, + head: headPointer, + index: indexPointerForEntry, chunks: chunkPointerItemsForEntry, }; - const headPointerKey = headPointerForEntry ? pointerKey(headPointerForEntry) : ""; - if (headPointerKey) { - chunkPointerKeys.add(headPointerKey); - if (entry.pointerEvent) { - entry.chunkEvents.set(headPointerKey, cloneEventForCache(entry.pointerEvent)); + 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) + ); + } } } @@ -3320,11 +3764,20 @@ class NostrClient { if (!normalizedIdentifier) { return "none"; } - const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); - if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) { + if (isIndexIdentifier(normalizedIdentifier)) { return "head"; } - if (!normalizedIdentifierLower.startsWith("watch-history:")) { + if (isLegacyHeadIdentifier(normalizedIdentifier)) { + return "head"; + } + const chunkPrefixLower = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX + ? WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX.trim().toLowerCase() + : ""; + const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); + const matchesPrefix = chunkPrefixLower + ? normalizedIdentifierLower.startsWith(chunkPrefixLower) + : normalizedIdentifierLower.startsWith("watch-history"); + if (!matchesPrefix) { return "none"; } const normalizedOwner = diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index b8fbb4de..96ff9d25 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -10,6 +10,7 @@ export const NOTE_TYPES = Object.freeze({ VIDEO_MIRROR: "videoMirror", RELAY_LIST: "relayList", VIEW_EVENT: "viewEvent", + WATCH_HISTORY_INDEX: "watchHistoryIndex", WATCH_HISTORY_CHUNK: "watchHistoryChunk", SUBSCRIPTION_LIST: "subscriptionList", USER_BLOCK_LIST: "userBlockList", @@ -91,13 +92,30 @@ const BASE_SCHEMAS = { description: "Optional plaintext content used for diagnostics.", }, }, + [NOTE_TYPES.WATCH_HISTORY_INDEX]: { + type: NOTE_TYPES.WATCH_HISTORY_INDEX, + label: "Watch history index", + kind: WATCH_HISTORY_KIND, + identifierTag: { + name: "d", + value: WATCH_HISTORY_LIST_IDENTIFIER, + }, + snapshotTagName: "snapshot", + totalTagName: "chunks", + chunkPointerTagName: "a", + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "json", + description: + "JSON payload describing the active snapshot and chunk count.", + }, + }, [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { type: NOTE_TYPES.WATCH_HISTORY_CHUNK, label: "Watch history snapshot", kind: WATCH_HISTORY_KIND, identifierTag: { name: "d", - value: WATCH_HISTORY_LIST_IDENTIFIER, }, encryptionTag: { name: "encrypted", value: "nip04" }, snapshotTagName: "snapshot", @@ -454,6 +472,73 @@ export function buildViewEvent({ }; } +export function buildWatchHistoryIndexEvent({ + pubkey, + created_at, + snapshotId, + totalChunks, + chunkAddresses = [], + additionalTags = [], + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = schema?.identifierTag?.value || WATCH_HISTORY_LIST_IDENTIFIER; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + const snapshotTagName = schema?.snapshotTagName || "snapshot"; + if (snapshotTagName && snapshotId) { + tags.push([snapshotTagName, snapshotId]); + } + const totalTagName = schema?.totalTagName || "chunks"; + if (totalTagName && Number.isFinite(totalChunks)) { + tags.push([totalTagName, String(Math.max(0, Math.floor(totalChunks)))]); + } + const pointerTagName = schema?.chunkPointerTagName || "a"; + chunkAddresses.forEach((address) => { + if (typeof address === "string" && address) { + tags.push([pointerTagName, address]); + } + }); + + appendSchemaTags(tags, schema); + + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + let resolvedContent = content; + if (resolvedContent === undefined) { + const payload = {}; + if (snapshotId) { + payload.snapshot = snapshotId; + } + if (Number.isFinite(totalChunks)) { + payload.totalChunks = Math.max(0, Math.floor(totalChunks)); + } + resolvedContent = JSON.stringify(payload); + } else if (typeof resolvedContent !== "string") { + resolvedContent = JSON.stringify(resolvedContent ?? {}); + } + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: + typeof resolvedContent === "string" + ? resolvedContent + : String(resolvedContent ?? ""), + }; +} + export function buildWatchHistoryChunkEvent({ pubkey, created_at, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index a7e25b40..f39c1291 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -5,6 +5,13 @@ const { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_KIND, } = await import("../js/config.js"); +const { + NOTE_TYPES, + getNostrEventSchema, + setNostrEventSchemaOverrides, + buildWatchHistoryIndexEvent, + buildWatchHistoryChunkEvent, +} = await import("../js/nostrEventSchemas.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -66,6 +73,132 @@ function createDecryptClient(actorPubkey) { } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const WATCH_HISTORY_CHUNK_PREFIX = WATCH_HISTORY_LIST_IDENTIFIER.replace( + /[:/]index$/i, + "" +); + +const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX); +assert(indexSchema, "index schema should be available"); + +const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); +assert(chunkSchema, "chunk schema should be available"); + +const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`; +const sampleChunkAddresses = [ + `${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`, + `${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`, +]; + +const sampleIndexEvent = buildWatchHistoryIndexEvent({ + pubkey: ACTOR, + created_at: 111, + snapshotId: "sample", + totalChunks: 2, + chunkAddresses: sampleChunkAddresses, +}); + +const sampleIndexIdentifierTag = sampleIndexEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" +); +assert(sampleIndexIdentifierTag, "index event should include identifier tag"); +assert.equal( + sampleIndexIdentifierTag[1], + WATCH_HISTORY_LIST_IDENTIFIER, + "index event should use canonical watch history identifier" +); + +const sampleChunksTag = sampleIndexEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "chunks" +); +assert(sampleChunksTag, "index event should include chunks tag"); +assert.equal( + Number.parseInt(sampleChunksTag[1], 10), + 2, + "chunks tag should reflect total chunks" +); + +const sampleIndexAddresses = sampleIndexEvent.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "a") + .map((tag) => tag[1]); +assert.deepEqual( + sampleIndexAddresses, + sampleChunkAddresses, + "index event should list chunk addresses" +); + +const sampleChunkEvent = buildWatchHistoryChunkEvent({ + pubkey: ACTOR, + created_at: 222, + chunkIdentifier: sampleChunkIdentifier, + snapshotId: "sample", + chunkIndex: 0, + totalChunks: 2, + pointerTags: [["e", "sample-event"]], + content: "ciphertext", +}); + +const chunkIdentifierTag = sampleChunkEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" +); +assert(chunkIdentifierTag, "chunk event should include identifier tag"); +assert.equal( + chunkIdentifierTag[1], + sampleChunkIdentifier, + "chunk event should use provided identifier" +); +assert( + sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"), + "chunk event should include snapshot tag" +); +assert( + sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"), + "chunk event should include chunk tag" +); +assert( + sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"), + "chunk event should include encrypted tag" +); + +setNostrEventSchemaOverrides({ + [NOTE_TYPES.WATCH_HISTORY_INDEX]: { + identifierTag: { name: "d", value: "custom:index" }, + }, + [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { + identifierTag: { name: "d", value: "custom:chunk" }, + }, +}); + +const overriddenIndexEvent = buildWatchHistoryIndexEvent({ + pubkey: ACTOR, + created_at: 333, + snapshotId: "override", + totalChunks: 1, +}); +assert( + overriddenIndexEvent.tags.some( + (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index" + ), + "index builder should honor overrides" +); + +const overriddenChunkEvent = buildWatchHistoryChunkEvent({ + pubkey: ACTOR, + created_at: 444, + snapshotId: "override", + chunkIndex: 0, + totalChunks: 1, + pointerTags: [], + content: "cipher", +}); +assert( + overriddenChunkEvent.tags.some( + (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk" + ), + "chunk builder should honor identifier override" +); + +setNostrEventSchemaOverrides({}); const { client: publishingClient, publishedEvents, payloads } = createPublishingClient(ACTOR); @@ -99,11 +232,11 @@ const publishResult = await publishingClient.publishWatchHistorySnapshot( ); assert.equal(publishResult.ok, true, "publish should report success"); -assert(publishResult.events.length > 1, "snapshot should chunk into multiple events"); +assert(publishResult.events.length > 1, "snapshot should include index and chunk events"); assert.equal( publishResult.events.length, - payloads.length, - "each chunk should produce an encryption payload" + payloads.length + 1, + "index event should accompany each chunk payload" ); payloads.forEach((payload) => { @@ -120,53 +253,67 @@ const snapshotIds = new Set( event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1] ) ); -assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id"); +assert.equal(snapshotIds.size, 1, "all events should share a snapshot id"); -const headTag = publishResult.event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "head" +const indexEvent = publishResult.indexEvent; +assert(indexEvent, "publish result should include index event"); + +const indexIdentifierTag = indexEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" +); +assert(indexIdentifierTag, "index event should include identifier tag"); +assert.equal( + indexIdentifierTag[1], + WATCH_HISTORY_LIST_IDENTIFIER, + "index event should use canonical identifier" ); -assert(headTag, "head chunk should include head tag"); -const chunkIdentifiers = publishResult.events.map((event) => +const indexChunksTag = indexEvent.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "chunks" +); +assert(indexChunksTag, "index event should include chunks tag"); +assert.equal( + Number.parseInt(indexChunksTag[1], 10), + publishResult.chunks.length, + "index event should report chunk count" +); + +const chunkEvents = publishResult.chunks.map((chunk) => chunk.event); +const chunkIdentifiers = chunkEvents.map((event) => event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] ); assert.equal( chunkIdentifiers.length, - publishResult.events.length, + chunkEvents.length, "each chunk should expose a d tag" ); -assert.equal( - chunkIdentifiers[0], - WATCH_HISTORY_LIST_IDENTIFIER, - "head chunk should retain canonical watch history identifier" -); - const uniqueChunkIdentifiers = new Set(chunkIdentifiers); assert.equal( uniqueChunkIdentifiers.size, - publishResult.events.length, + chunkEvents.length, "each chunk should have a unique d tag" ); -chunkIdentifiers.slice(1).forEach((identifier, index) => { +chunkIdentifiers.forEach((identifier, index) => { + assert(identifier, `chunk ${index} should include identifier`); assert( - identifier && identifier.startsWith("watch-history:"), - `chunk ${index + 1} should use a watch-history namespace identifier` + identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX), + `chunk ${index} should use the chunk prefix` ); assert.notEqual( identifier, WATCH_HISTORY_LIST_IDENTIFIER, - "only the head chunk should use the canonical identifier" + "chunk identifiers should differ from index identifier" ); }); -const headATags = publishResult.event.tags +const indexAddresses = indexEvent.tags .filter((tag) => Array.isArray(tag) && tag[0] === "a") .map((tag) => tag[1]); -const expectedChunkAddresses = publishResult.events.map((event) => { +const expectedChunkAddresses = chunkEvents.map((event) => { const identifier = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" )?.[1]; @@ -176,8 +323,8 @@ const expectedChunkAddresses = publishResult.events.map((event) => { expectedChunkAddresses.forEach((address) => { assert( - headATags.includes(address), - `head chunk should reference ${address}` + indexAddresses.includes(address), + `index event should reference ${address}` ); }); @@ -229,9 +376,6 @@ if (typeof localStorage !== "undefined") { } const headEvent = publishResult.event; -const chunkEvents = publishResult.events.filter( - (event) => event.id !== headEvent.id -); const snapshotId = headEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "snapshot" From c70a107d2f8c7fd155f89c414db40897382279cb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:30:33 -0400 Subject: [PATCH 24/82] Refine watch history snapshot publishing and refresh --- js/nostr.js | 404 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 303 insertions(+), 101 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index d82503ab..4f00aaf4 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -53,7 +53,9 @@ const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER = ? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase() : ""; -const WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => { +const WATCH_HISTORY_CHUNK_V2_PREFIX = "watch-history:v2"; + +const LEGACY_WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => { const rawIdentifier = typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" ? WATCH_HISTORY_LIST_IDENTIFIER.trim() @@ -69,6 +71,13 @@ const WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => { 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 @@ -87,8 +96,7 @@ const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => { })(); function deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) { - const prefix = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX || "watch-history"; - const sanitizedPrefix = prefix.replace(/\/+$/, ""); + const sanitizedPrefix = WATCH_HISTORY_CHUNK_V2_PREFIX.replace(/\/+$/, ""); const safeSnapshot = typeof snapshotId === "string" && snapshotId ? snapshotId : "0"; const safeIndex = Number.isFinite(chunkIndex) @@ -118,6 +126,97 @@ function isLegacyHeadIdentifier(identifier) { 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; @@ -1218,6 +1317,7 @@ class NostrClient { 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; } @@ -1886,6 +1986,42 @@ class NostrClient { 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, @@ -2037,6 +2173,47 @@ class NostrClient { const chunkResults = []; let overallSuccess = true; + const indexEvent = buildWatchHistoryIndexEvent({ + pubkey: normalizedActor, + created_at: baseTimestamp + totalChunks, + snapshotId, + totalChunks, + chunkAddresses, + }); + + let signedIndexEvent = null; + 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; + } + + let indexPublishResults = []; + let indexSuccess = false; + 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 + ); + } + } else { + overallSuccess = false; + } + for (let index = 0; index < totalChunks; index++) { const chunkItems = Array.isArray(chunkItemsList[index]) ? chunkItemsList[index] @@ -2085,6 +2262,7 @@ class NostrClient { chunkIndex: index, totalChunks, pointerTags, + chunkAddresses, content: encryptionResult.ciphertext, }); @@ -2135,50 +2313,6 @@ class NostrClient { }); } - const indexEvent = buildWatchHistoryIndexEvent({ - pubkey: normalizedActor, - created_at: baseTimestamp + totalChunks, - snapshotId, - totalChunks, - chunkAddresses, - }); - - let signedIndexEvent = null; - 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; - } - - let indexPublishResults = []; - let indexSuccess = false; - 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 - ); - } - } else { - overallSuccess = false; - } - if (overallSuccess) { this.cancelWatchHistoryRepublish(normalizedActor); } else if (allowRetry && persistedItems.length) { @@ -2923,7 +3057,9 @@ class NostrClient { return { ...result, removedKey: targetKey }; } - async fetchWatchHistory(pubkeyOrSession) { + async fetchWatchHistory(pubkeyOrSession, options = {}) { + const forceRefresh = options?.forceRefresh === true; + const background = options?.background === true; let actor = typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() ? pubkeyOrSession.trim() @@ -2940,8 +3076,12 @@ class NostrClient { const now = Date.now(); const ttl = this.getWatchHistoryCacheTtlMs(); const cached = this.watchHistoryCache.get(actor); - if (cached) { + const priorEntry = cached || null; + if (!forceRefresh && cached) { if (now - cached.savedAt < ttl) { + if (!background) { + this.ensureWatchHistoryBackgroundRefresh(actor); + } return { pointerEvent: cached.pointerEvent ? cloneEventForCache(cached.pointerEvent) @@ -2960,6 +3100,7 @@ class NostrClient { storage = this.getWatchHistoryStorage(); stored = storage.actors?.[actor]; if ( + !forceRefresh && stored && typeof stored.savedAt === "number" && now - stored.savedAt < ttl @@ -2971,6 +3112,9 @@ class NostrClient { this.watchHistoryCache.get(actor) || null ); this.watchHistoryCache.set(actor, entry); + if (!background) { + this.ensureWatchHistoryBackgroundRefresh(actor); + } return { pointerEvent: entry.pointerEvent ? cloneEventForCache(entry.pointerEvent) @@ -2982,12 +3126,15 @@ class NostrClient { } } + const cachedSnapshotId = getWatchHistoryEntrySnapshotId(priorEntry); + let snapshotChanged = false; + if (!this.pool) { const fallbackEntry = this.createWatchHistoryEntry( null, [], Date.now(), - this.watchHistoryCache.get(actor) || null + priorEntry ); this.watchHistoryCache.set(actor, fallbackEntry); return { pointerEvent: null, items: [] }; @@ -3001,64 +3148,97 @@ class NostrClient { : WATCH_HISTORY_FETCH_EVENT_LIMIT; const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate)); - const headIdentifierSet = new Set(); - if ( + const indexIdentifier = typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" && WATCH_HISTORY_LIST_IDENTIFIER.trim() - ) { - headIdentifierSet.add(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()) { - headIdentifierSet.add(value.trim()); + const normalized = value.trim(); + if (normalized && normalized !== indexIdentifier) { + legacyIdentifierSet.add(normalized); + } } }); } - if (!headIdentifierSet.size && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) { - headIdentifierSet.add(WATCH_HISTORY_LIST_IDENTIFIER); + + if (!indexIdentifiers.length && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) { + indexIdentifiers.push(WATCH_HISTORY_LIST_IDENTIFIER); } - const filter = { - kinds: [WATCH_HISTORY_KIND], - authors: [actor], - limit: fetchLimit, + 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 (headIdentifierSet.size) { - filter["#d"] = Array.from(headIdentifierSet); + if (indexIdentifiers.length) { + const indexFilter = { + kinds: [WATCH_HISTORY_KIND], + 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 + ); + } + } } - let fetchedHeadEvents = []; - try { - const events = await this.pool.list(relayList, [filter]); - if (Array.isArray(events)) { - const dedupe = new Map(); - for (const evt of events) { - if (!evt || typeof evt !== "object") { - continue; - } - const id = typeof evt.id === "string" ? evt.id : null; - if (!id) { - continue; - } - const existing = dedupe.get(id); - if ( - !existing || - (typeof evt.created_at === "number" ? evt.created_at : 0) > - (typeof existing.created_at === "number" ? existing.created_at : 0) - ) { - dedupe.set(id, evt); - } + if (legacyIdentifierSet.size) { + const legacyFilter = { + kinds: [WATCH_HISTORY_KIND], + 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 + ); } - fetchedHeadEvents = Array.from(dedupe.values()); - } - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to fetch watch history list:", 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; @@ -3222,6 +3402,33 @@ class NostrClient { } } + 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); + } + const chunkFilters = []; const chunkFetchLimit = Math.max( fetchLimit, @@ -3512,9 +3719,11 @@ class NostrClient { const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null; - const previousEntry = this.watchHistoryCache.get(actor) || null; - const storedItems = Array.isArray(stored?.items) ? stored.items : []; - const storedPointerEvent = stored?.pointerEvent || null; + const previousEntry = snapshotChanged ? null : priorEntry; + const storedItems = + !snapshotChanged && Array.isArray(stored?.items) ? stored.items : []; + const storedPointerEvent = + !snapshotChanged && stored?.pointerEvent ? stored.pointerEvent : null; const remoteItems = Array.isArray(effectiveSnapshot?.items) ? effectiveSnapshot.items @@ -3770,14 +3979,7 @@ class NostrClient { if (isLegacyHeadIdentifier(normalizedIdentifier)) { return "head"; } - const chunkPrefixLower = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX - ? WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX.trim().toLowerCase() - : ""; - const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); - const matchesPrefix = chunkPrefixLower - ? normalizedIdentifierLower.startsWith(chunkPrefixLower) - : normalizedIdentifierLower.startsWith("watch-history"); - if (!matchesPrefix) { + if (!isWatchHistoryChunkIdentifier(normalizedIdentifier)) { return "none"; } const normalizedOwner = From ff452ebe772e8ff8decca20583aa430105d4c56c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:40:10 -0400 Subject: [PATCH 25/82] Stop default view events from adding legacy tags --- docs/nostr-event-schemas.md | 2 +- js/nostrEventSchemas.js | 28 ++++++++++++++++++++------- tests/view-counter.test.mjs | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index a5c88dd9..158359b2 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -48,7 +48,7 @@ read/write split. | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | -| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', ]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | Canonical tag set: `['t','view']` plus either `['e', ]` or `['a',
]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 96ff9d25..869bf1b3 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -83,8 +83,6 @@ const BASE_SCHEMAS = { label: "View counter", kind: WATCH_HISTORY_KIND, topicTag: { name: "t", value: "view" }, - pointerTagName: "video", - identifierTag: { name: "d" }, sessionTag: { name: "session", value: "true" }, appendTags: DEFAULT_APPEND_TAGS, content: { @@ -418,6 +416,7 @@ export function buildViewEvent({ created_at, pointerValue, pointerTag, + pointerTags = [], dedupeTag, includeSessionTag = false, additionalTags = [], @@ -429,13 +428,28 @@ export function buildViewEvent({ tags.push([schema.topicTag.name, schema.topicTag.value]); } - const pointerTagName = schema?.pointerTagName || "video"; - if (pointerValue) { + const pointerTagName = schema?.pointerTagName; + if (pointerValue && pointerTagName) { tags.push([pointerTagName, pointerValue]); } + const normalizedPointerTags = []; if (Array.isArray(pointerTag) && pointerTag.length >= 2) { - tags.push(pointerTag.map((value) => (typeof value === "string" ? value : String(value)))); + normalizedPointerTags.push( + pointerTag.map((value) => (typeof value === "string" ? value : String(value))) + ); } + if (Array.isArray(pointerTags)) { + pointerTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + normalizedPointerTags.push( + tag.map((value) => (typeof value === "string" ? value : String(value))) + ); + } + }); + } + normalizedPointerTags.forEach((tag) => { + tags.push(tag); + }); if (Array.isArray(additionalTags)) { additionalTags.forEach((tag) => { if (Array.isArray(tag) && tag.length >= 2) { @@ -444,8 +458,8 @@ export function buildViewEvent({ }); } - if (dedupeTag) { - const identifierName = schema?.identifierTag?.name || "d"; + if (dedupeTag && schema?.identifierTag?.name) { + const identifierName = schema.identifierTag.name; const hasDedupe = tags.some( (tag) => tag[0] === identifierName && tag[1] === dedupeTag ); diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 897ccdda..d627b8eb 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -9,6 +9,10 @@ const { VIEW_COUNT_CACHE_TTL_MS, } = await import("../js/config.js"); +const { buildViewEvent, setNostrEventSchemaOverrides } = await import( + "../js/nostrEventSchemas.js" +); + const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1"; const CACHE_TTL_TEST_POINTER = { type: "e", value: "view-counter-cache-ttl" }; @@ -42,6 +46,40 @@ if (!globalThis.window.NostrTools) { globalThis.window.NostrTools = {}; } +setNostrEventSchemaOverrides({}); + +const canonicalViewEvent = buildViewEvent({ + pubkey: "actor-canonical", + created_at: 1000, + pointerValue: CACHE_TTL_TEST_POINTER.value, + pointerTag: [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value], + dedupeTag: "ignore-dedupe", +}); + +assert.deepEqual( + canonicalViewEvent.tags, + [["t", "view"], [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value]], + "view event should only include topic and supplied pointer tags by default" +); + +const sessionViewEvent = buildViewEvent({ + pubkey: "actor-session", + created_at: 2000, + pointerValue: "kind:1234:actor-session", + pointerTag: ["a", "kind:1234:actor-session"], + includeSessionTag: true, +}); + +assert.deepEqual( + sessionViewEvent.tags, + [ + ["t", "view"], + ["a", "kind:1234:actor-session"], + ["session", "true"], + ], + "view event should append the session tag when requested" +); + const { nostrClient } = await import("../js/nostr.js"); function createMockNostrHarness() { From 09c8e275c421266a3e6d6fb7d869d0755b344129 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:53:23 -0400 Subject: [PATCH 26/82] Add client-side guard for view event publishing --- js/constants.js | 31 ++++++ js/nostr.js | 247 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 241 insertions(+), 37 deletions(-) diff --git a/js/constants.js b/js/constants.js index 38600fe9..d3abdaef 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,6 +1,7 @@ const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: true, // try URL before magnet in the player ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes + VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", "wss://tracker.btorrent.xyz", @@ -17,6 +18,8 @@ const runtimeFlags = (() => { const initial = { URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED, ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1, + VIEW_FILTER_INCLUDE_LEGACY_VIDEO: + DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], }; if (globalScope) { @@ -92,6 +95,11 @@ export let ACCEPT_LEGACY_V1 = coerceBoolean( DEFAULT_FLAGS.ACCEPT_LEGACY_V1 ); +export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean( + runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, + DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS) ); @@ -118,6 +126,20 @@ Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", { }, }); +Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", { + configurable: true, + enumerable: true, + get() { + return VIEW_FILTER_INCLUDE_LEGACY_VIDEO; + }, + set(next) { + VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean( + next, + DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -132,6 +154,7 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { // Ensure the runtime object reflects the sanitized defaults immediately. runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED; runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1; +runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; export function setUrlFirstEnabled(next) { @@ -144,6 +167,11 @@ export function setAcceptLegacyV1(next) { return ACCEPT_LEGACY_V1; } +export function setViewFilterIncludeLegacyVideo(next) { + runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = next; + return VIEW_FILTER_INCLUDE_LEGACY_VIDEO; +} + export function setWssTrackers(next) { runtimeFlags.WSS_TRACKERS = next; return WSS_TRACKERS; @@ -152,6 +180,9 @@ export function setWssTrackers(next) { export function resetRuntimeFlags() { setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED); setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1); + setViewFilterIncludeLegacyVideo( + DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO + ); setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS); } diff --git a/js/nostr.js b/js/nostr.js index 4f00aaf4..9f11c393 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -12,7 +12,10 @@ import { WATCH_HISTORY_CACHE_TTL_MS, VIEW_COUNT_DEDUPE_WINDOW_SECONDS, } from "./config.js"; -import { ACCEPT_LEGACY_V1 } from "./constants.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"; @@ -47,6 +50,7 @@ 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" @@ -95,6 +99,31 @@ const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => { return normalized; })(); +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 = @@ -541,13 +570,17 @@ function createVideoViewEventFilters(pointer) { pointerFilter["#e"] = [resolved.value]; } - const legacyFilter = { - kinds: [WATCH_HISTORY_KIND], - "#t": ["view"], - "#video": [resolved.value], - }; + const filters = [pointerFilter]; - return { pointer: resolved, filters: [pointerFilter, legacyFilter] }; + if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) { + filters.push({ + kinds: [WATCH_HISTORY_KIND], + "#t": ["view"], + "#video": [resolved.value], + }); + } + + return { pointer: resolved, filters }; } function deriveViewEventBucketIndex(createdAtSeconds) { @@ -561,28 +594,124 @@ function deriveViewEventBucketIndex(createdAtSeconds) { return Math.floor(timestamp / windowSize); } -function deriveViewEventDedupTag(actorPubkey, pointer, createdAtSeconds) { - if (typeof actorPubkey !== "string") { - return null; - } +function getViewEventGuardWindowMs() { + const windowSeconds = Math.max( + 1, + Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0 + ); + return windowSeconds * 1000; +} - const normalizedActor = actorPubkey.trim().toLowerCase(); - if (!normalizedActor) { - return null; +function deriveViewEventPointerScope(pointer) { + const pointerValue = + typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : ""; + if (!pointerValue) { + return ""; } - - const pointerValueRaw = - typeof pointer?.value === "string" ? pointer.value.trim() : ""; - if (!pointerValueRaw) { - return null; - } - const pointerType = pointer?.type === "a" ? "a" : "e"; - const pointerValue = pointerValueRaw.toLowerCase(); - const bucket = deriveViewEventBucketIndex(createdAtSeconds); - const scope = pointerType === "a" ? `a:${pointerValue}` : `e:${pointerValue}`; + return `${pointerType}:${pointerValue}`; +} - return `bitvid:view:${normalizedActor}:${scope}:${bucket}`; +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) { @@ -598,7 +727,9 @@ function isVideoViewEvent(event, pointer) { let hasViewTag = false; let matchesPointer = false; - const pointerValue = typeof pointer?.value === "string" ? pointer.value : ""; + 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) { @@ -618,21 +749,34 @@ function isVideoViewEvent(event, pointer) { continue; } - if (matchesPointer || !pointerValue) { + if (matchesPointer || !pointerValueLower) { continue; } - if (label === "video" && value === pointerValue) { + if ( + VIEW_FILTER_INCLUDE_LEGACY_VIDEO && + label === "video" && + value.toLowerCase() === pointerValueLower + ) { matchesPointer = true; continue; } - if (pointerType === "a" && label === "a" && value === pointerValue) { - matchesPointer = true; + const pointerTag = normalizePointerTag(tag); + if (!pointerTag) { continue; } - if (pointerType === "e" && label === "e" && value === pointerValue) { + const tagValueLower = + typeof pointerTag.value === "string" + ? pointerTag.value.trim().toLowerCase() + : ""; + + if (!tagValueLower || tagValueLower !== pointerValueLower) { + continue; + } + + if (pointerTag.type === pointerType) { matchesPointer = true; continue; } @@ -2582,6 +2726,21 @@ class NostrClient { ? 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 = @@ -2604,11 +2763,6 @@ class NostrClient { ? ["e", pointer.value, pointer.relay] : ["e", pointer.value]; - const dedupeTagValue = deriveViewEventDedupTag( - actorPubkey, - pointer, - createdAt - ); const content = typeof options.content === "string" ? options.content : ""; @@ -2617,7 +2771,6 @@ class NostrClient { created_at: createdAt, pointerValue: pointer.value, pointerTag, - dedupeTag: dedupeTagValue, includeSessionTag: usingSessionActor, additionalTags, content, @@ -2670,6 +2823,9 @@ class NostrClient { .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(", ") @@ -4265,6 +4421,23 @@ class NostrClient { } 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 + ); + } + } + } + const history = await this.updateWatchHistoryList(pointer); return { From 6b1602d170b183309260870f5fd6df1f9aacbd85 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:30:39 -0400 Subject: [PATCH 27/82] Ensure playback uses fallback pointer for view logging --- js/app.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/js/app.js b/js/app.js index 2143055e..48ec0bfd 100644 --- a/js/app.js +++ b/js/app.js @@ -10615,13 +10615,26 @@ class bitvidApp { typeof (video.id || eventId) === "string" ? (video.id || eventId).trim() : ""; - const fallbackPointer = - !primaryPointer && fallbackId - ? ["e", fallbackId] - : null; + const fallbackPointer = fallbackId ? ["e", fallbackId] : null; - const resolvedPointer = primaryPointer || fallbackPointer; - const resolvedPointerKey = pointerArrayToKey(resolvedPointer); + let resolvedPointer = null; + let resolvedPointerKey = ""; + const pointerCandidates = []; + if (primaryPointer) { + pointerCandidates.push(primaryPointer); + } + if (fallbackPointer) { + pointerCandidates.push(fallbackPointer); + } + + for (const candidate of pointerCandidates) { + const key = pointerArrayToKey(candidate); + if (key) { + resolvedPointer = candidate; + resolvedPointerKey = key; + break; + } + } this.currentVideoPointer = resolvedPointer && resolvedPointerKey ? resolvedPointer From a025dd210f49cb1833daf3266486ddb92bb0061d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:07:39 -0400 Subject: [PATCH 28/82] Refine watch history snapshot publishing --- js/nostr.js | 115 +++++++++++++---------- tests/watch-history.test.mjs | 171 +++++++++++++++++++++++++++++++++-- 2 files changed, 231 insertions(+), 55 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 9f11c393..dd97a339 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2314,49 +2314,7 @@ class NostrClient { } const baseTimestamp = Math.max(nowSeconds, lastCreatedAt + totalChunks); - const chunkResults = []; - let overallSuccess = true; - - const indexEvent = buildWatchHistoryIndexEvent({ - pubkey: normalizedActor, - created_at: baseTimestamp + totalChunks, - snapshotId, - totalChunks, - chunkAddresses, - }); - - let signedIndexEvent = null; - 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; - } - - let indexPublishResults = []; - let indexSuccess = false; - 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 - ); - } - } else { - overallSuccess = false; - } + const preparedChunks = []; for (let index = 0; index < totalChunks; index++) { const chunkItems = Array.isArray(chunkItemsList[index]) @@ -2436,8 +2394,18 @@ class NostrClient { } } + 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, signedEvent)) + relays.map((url) => publishEventToRelay(this.pool, url, chunk.event)) ); const chunkSuccess = publishResults.some((result) => result.success); @@ -2450,13 +2418,62 @@ class NostrClient { } chunkResults.push({ - chunkIndex: index, - event: signedEvent, + 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; + } + if (overallSuccess) { this.cancelWatchHistoryRepublish(normalizedActor); } else if (allowRetry && persistedItems.length) { @@ -2466,7 +2483,11 @@ class NostrClient { const baselineEntry = existingEntry || this.watchHistoryCache.get(normalizedActor) || null; - const pointerEvent = signedIndexEvent || chunkResults[0]?.event || null; + const pointerEvent = + signedIndexEvent || + chunkResults.find((chunk) => chunk.success)?.event || + chunkResults[0]?.event || + null; const newEntry = this.createWatchHistoryEntry( pointerEvent, persistedItems, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index f39c1291..2d8a646a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -34,18 +34,41 @@ delete globalThis.window.nostr; const { nostrClient } = await import("../js/nostr.js"); -function createPublishingClient(actorPubkey) { +function createPublishingClient(actorPubkey, options = {}) { const client = new nostrClient.constructor(); const publishedEvents = []; const payloads = []; + const scheduledRepublishes = []; + const cancelledRepublishes = []; + const persistedEntries = []; + const onPublish = + typeof options.onPublish === "function" ? options.onPublish : null; + client.pool = { publish(urls, event) { publishedEvents.push(event); + const publishDecision = onPublish ? onPublish(event) : null; + const shouldSucceed = publishDecision?.ok !== false; return { on(eventName, handler) { - if (eventName === "ok" || eventName === "seen") { - setTimeout(handler, 0); - } + setTimeout(() => { + if (shouldSucceed) { + if (eventName === "ok" || eventName === "seen") { + handler(); + } + } else if (eventName === "failed") { + const providedError = publishDecision?.error; + const error = + providedError instanceof Error + ? providedError + : new Error( + providedError + ? String(providedError) + : "mock publish failure" + ); + handler(error); + } + }, 0); }, }; }, @@ -59,10 +82,23 @@ function createPublishingClient(actorPubkey) { payloads.push(payload); return { ok: true, ciphertext: JSON.stringify(payload) }; }; - client.persistWatchHistoryEntry = () => {}; - client.cancelWatchHistoryRepublish = () => {}; - client.scheduleWatchHistoryRepublish = () => {}; - return { client, publishedEvents, payloads }; + client.persistWatchHistoryEntry = (_actor, entry) => { + persistedEntries.push(entry); + }; + client.cancelWatchHistoryRepublish = (actor) => { + cancelledRepublishes.push(actor); + }; + client.scheduleWatchHistoryRepublish = (actor, items) => { + scheduledRepublishes.push({ actor, items }); + }; + return { + client, + publishedEvents, + payloads, + scheduledRepublishes, + cancelledRepublishes, + persistedEntries, + }; } function createDecryptClient(actorPubkey) { @@ -328,6 +364,125 @@ expectedChunkAddresses.forEach((address) => { ); }); +const CHUNK_FAILURE_ACTOR = `${ACTOR}-chunk-failure`; +const { + client: chunkFailureClient, + publishedEvents: chunkFailureEvents, + payloads: chunkFailurePayloads, + scheduledRepublishes: chunkFailureRepublishes, + cancelledRepublishes: chunkFailureCancels, +} = createPublishingClient(CHUNK_FAILURE_ACTOR, { + onPublish(event) { + const chunkTag = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "chunk" + ); + if (chunkTag) { + const chunkIndex = Number.parseInt(chunkTag[1], 10); + const total = Number.parseInt(chunkTag[2], 10); + if ( + Number.isInteger(chunkIndex) && + Number.isInteger(total) && + (total > 1 ? chunkIndex === total - 1 : chunkIndex === 0) + ) { + return { ok: false, error: new Error("chunk publish failed") }; + } + } + return { ok: true }; + }, +}); + +chunkFailureClient.watchHistoryPayloadMaxBytes = + publishingClient.watchHistoryPayloadMaxBytes; +chunkFailureClient.watchHistoryFetchEventLimit = 10; + +const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot( + CHUNK_FAILURE_ACTOR, + longPointers +); + +assert.equal( + chunkFailureResult.ok, + false, + "chunk failure should report overall failure" +); +assert.equal( + chunkFailureResult.indexEvent, + null, + "chunk failure should omit index event" +); +assert.equal( + chunkFailureResult.events.length, + chunkFailureResult.chunks.length, + "chunk failure should not include index event in combined events" +); +assert.equal( + chunkFailureResult.indexResults.length, + 0, + "chunk failure should skip index publish attempts" +); +assert( + chunkFailureResult.chunks.some((chunk) => chunk.success === false), + "chunk failure results should flag the failed chunk" +); +assert.equal( + chunkFailureRepublishes.length, + 1, + "chunk failure should schedule a retry" +); +assert.equal( + chunkFailureCancels.length, + 0, + "chunk failure should not cancel retries" +); +assert.equal( + chunkFailureRepublishes[0].actor, + CHUNK_FAILURE_ACTOR, + "chunk failure retry should target the affected actor" +); +assert( + chunkFailureEvents.every((event) => + event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") + ), + "chunk failure should avoid publishing index events" +); +assert.equal( + chunkFailurePayloads.length, + chunkFailureResult.chunks.length, + "chunk failure should still prepare payloads for each chunk" +); + +const chunkFailureCache = chunkFailureClient.watchHistoryCache.get( + CHUNK_FAILURE_ACTOR +); + +assert(chunkFailureCache, "chunk failure should cache a watch history entry"); +assert.equal( + chunkFailureCache.chunkPointers.index, + null, + "chunk failure cache should not retain an index pointer" +); +assert( + Array.from(chunkFailureCache.chunkEvents.values()).every((event) => + event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") + ), + "chunk failure cache should only persist chunk events" +); +assert.deepEqual( + chunkFailureRepublishes[0].items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + chunkFailureResult.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + watchedAt: item.watchedAt, + })), + "republish scheduler should receive the remaining items" +); + delete globalThis.window.nostr; if (!globalThis.window.NostrTools.nip04) { From d1a9b8c79d5c371ee7e8fdcbb1320f3391b34660 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:37:53 -0400 Subject: [PATCH 29/82] Limit view count fallback window --- js/nostr.js | 51 +++++++++- tests/nostr-count-fallback.test.mjs | 151 ++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 tests/nostr-count-fallback.test.mjs diff --git a/js/nostr.js b/js/nostr.js index dd97a339..79f12ef1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -11,6 +11,7 @@ import { 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, @@ -3052,8 +3053,51 @@ class NostrClient { } 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, options); + const events = await this.listVideoViewEvents(pointer, { + ...fallbackListOptions, + }); return { total: Array.isArray(events) ? events.length : 0, perRelay: [], @@ -3067,9 +3111,6 @@ class NostrClient { if (!pointerFilter || typeof pointerFilter !== "object") { throw new Error("Invalid video pointer supplied for view lookup."); } - const relayList = Array.isArray(options?.relays) && options.relays.length - ? options.relays - : undefined; const signal = options?.signal; const normalizeAbortError = () => { @@ -3122,7 +3163,7 @@ class NostrClient { : null; const listPromise = this.listVideoViewEvents(pointer, { - relays: relayList, + ...fallbackListOptions, }); const events = abortPromise diff --git a/tests/nostr-count-fallback.test.mjs b/tests/nostr-count-fallback.test.mjs new file mode 100644 index 00000000..fc7a3487 --- /dev/null +++ b/tests/nostr-count-fallback.test.mjs @@ -0,0 +1,151 @@ +// Run with: node tests/nostr-count-fallback.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const { VIEW_COUNT_BACKFILL_MAX_DAYS } = await import("../js/config.js"); +const { nostrClient } = await import("../js/nostr.js"); + +const NostrClient = nostrClient.constructor; + +async function withFrozenTime(timestampSeconds, callback) { + const originalNow = Date.now; + Date.now = () => timestampSeconds * 1000; + try { + return await callback(); + } finally { + Date.now = originalNow; + } +} + +function getConfiguredHorizonDays() { + const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS); + return Number.isFinite(horizonDaysRaw) + ? Math.max(0, Math.floor(horizonDaysRaw)) + : 0; +} + +async function testFallbackAddsDefaultSinceWhenPoolMissing() { + const client = new NostrClient(); + client.pool = null; + + const pointer = { type: "e", value: "count-fallback-default" }; + const calls = []; + client.listVideoViewEvents = async (_, options = {}) => { + calls.push({ ...options }); + return []; + }; + + const horizonDays = getConfiguredHorizonDays(); + + const nowSeconds = 1_700_000_000; + await withFrozenTime(nowSeconds, async () => { + const result = await client.countVideoViewEvents(pointer, {}); + assert.equal(calls.length, 1, "fallback should issue a single list query"); + const [{ since }] = calls; + if (horizonDays > 0) { + const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400); + assert.equal( + since, + expectedSince, + "fallback list should clamp history to the configured horizon" + ); + } else { + assert.equal( + Object.prototype.hasOwnProperty.call(calls[0], "since"), + false, + "without a configured horizon the fallback should avoid forcing a since filter" + ); + } + assert.equal(result.fallback, true, "fallback branch should be flagged"); + }); +} + +async function testFallbackHonorsCallerOverrides() { + const client = new NostrClient(); + client.pool = null; + + const pointer = { type: "e", value: "count-fallback-overrides" }; + let receivedOptions = null; + client.listVideoViewEvents = async (_, options = {}) => { + receivedOptions = { ...options }; + return []; + }; + + const overrides = { + since: 1_234_567, + until: 1_235_000, + limit: 42, + }; + + await client.countVideoViewEvents(pointer, overrides); + + assert.ok(receivedOptions, "fallback should forward overrides to list"); + assert.equal( + receivedOptions.since, + Math.floor(overrides.since), + "caller supplied since should be preserved" + ); + assert.equal( + receivedOptions.until, + Math.floor(overrides.until), + "caller supplied until should be preserved" + ); + assert.equal( + receivedOptions.limit, + Math.floor(overrides.limit), + "caller supplied limit should be preserved" + ); +} + +async function testFallbackAfterCountFailureUsesDefaultWindow() { + const client = new NostrClient(); + client.pool = {}; + client.countEventsAcrossRelays = async () => { + throw new Error("count failed"); + }; + + const pointer = { type: "e", value: "count-fallback-error" }; + let receivedOptions = null; + client.listVideoViewEvents = async (_, options = {}) => { + receivedOptions = { ...options }; + return []; + }; + + const horizonDays = getConfiguredHorizonDays(); + + const nowSeconds = 1_800_000_000; + await withFrozenTime(nowSeconds, async () => { + await client.countVideoViewEvents(pointer, {}); + }); + + assert.ok(receivedOptions, "fallback should invoke the list helper"); + if (horizonDays > 0) { + const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400); + assert.equal( + receivedOptions.since, + expectedSince, + "COUNT fallback should restrict scans to the configured horizon" + ); + } else { + assert.equal( + Object.prototype.hasOwnProperty.call(receivedOptions, "since"), + false, + "COUNT fallback should avoid forcing a since filter when no horizon is set" + ); + } +} + +await testFallbackAddsDefaultSinceWhenPoolMissing(); +await testFallbackHonorsCallerOverrides(); +await testFallbackAfterCountFailureUsesDefaultWindow(); + +console.log("nostr COUNT fallback tests completed successfully."); From cf99059564d29bb0c814388aed82f644e3b4512c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:48:05 -0400 Subject: [PATCH 30/82] Add default view event payload and cover recordVideoView --- js/nostr.js | 46 ++++++++++++- js/nostrEventSchemas.js | 67 +++++++++++++++++- tests/view-counter.test.mjs | 134 ++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 3 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 79f12ef1..6b280b2f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2785,8 +2785,50 @@ class NostrClient { ? ["e", pointer.value, pointer.relay] : ["e", pointer.value]; - const content = - typeof options.content === "string" ? options.content : ""; + 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 event = buildViewEvent({ pubkey: actorPubkey, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 869bf1b3..9f3720b1 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -30,6 +30,69 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({ const DEFAULT_APPEND_TAGS = []; +let cachedUtf8Encoder = null; + +function ensureValidUtf8Content(value) { + let normalized = ""; + if (typeof value === "string") { + normalized = value; + } else if (value === undefined || value === null) { + normalized = ""; + } else if (typeof value === "object") { + try { + const stringified = JSON.stringify(value); + normalized = typeof stringified === "string" ? stringified : ""; + } catch (error) { + if (isDevMode) { + console.warn( + "[nostrEventSchemas] Failed to serialize view event content:", + error + ); + } + normalized = ""; + } + } else { + normalized = String(value); + } + + if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") { + cachedUtf8Encoder = new TextEncoder(); + } + + if (cachedUtf8Encoder) { + try { + cachedUtf8Encoder.encode(normalized); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostrEventSchemas] Dropping invalid UTF-8 characters from event content", + error + ); + } + const sanitized = Array.from(normalized) + .filter((char) => { + const code = char.charCodeAt(0); + return code < 0xd800 || code > 0xdfff; + }) + .join(""); + try { + cachedUtf8Encoder.encode(sanitized); + normalized = sanitized; + } catch (encodeError) { + if (isDevMode) { + console.warn( + "[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string", + encodeError + ); + } + normalized = ""; + } + } + } + + return normalized; +} + const BASE_SCHEMAS = { [NOTE_TYPES.VIDEO_POST]: { type: NOTE_TYPES.VIDEO_POST, @@ -477,12 +540,14 @@ export function buildViewEvent({ } appendSchemaTags(tags, schema); + const resolvedContent = ensureValidUtf8Content(content); + return { kind: schema?.kind ?? WATCH_HISTORY_KIND, pubkey, created_at, tags, - content: typeof content === "string" ? content : String(content ?? ""), + content: resolvedContent, }; } diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index d627b8eb..88e1ff26 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -506,6 +506,139 @@ async function testUnsubscribeStopsCallbacks() { ); } +async function testRecordVideoViewEmitsJsonPayload() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const pointer = { + type: "a", + value: "30078:pub:view-json", + relay: "wss://relay.example", + }; + const createdAt = Math.floor(Date.now() / 1000); + + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalEnsureSessionActor = nostrClient.ensureSessionActor; + const originalSessionActor = nostrClient.sessionActor; + const originalPubkey = nostrClient.pubkey; + const originalUpdateHistory = nostrClient.updateWatchHistoryList; + const originalWarn = console.warn; + const originalNostrTools = window.NostrTools; + + const warnings = []; + console.warn = (...args) => { + warnings.push( + args + .map((value) => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch (error) { + return String(value); + } + }) + .join(" ") + ); + }; + + window.NostrTools = { + ...window.NostrTools, + getEventHash: () => "event-hash-record", // deterministic stub for tests + signEvent: () => "event-sig-record", + }; + + const publishCalls = []; + nostrClient.pool = { + publish(relayUrls, event) { + publishCalls.push({ relays: relayUrls, event }); + return { + on(type, handler) { + if (type === "ok") { + setTimeout(() => handler(), 0); + } + return this; + }, + }; + }, + }; + nostrClient.relays = ["wss://relay.example"]; + nostrClient.ensureSessionActor = async () => { + nostrClient.sessionActor = { + pubkey: "pub-record-json", + privateKey: "priv-record-json", + }; + return "pub-record-json"; + }; + nostrClient.pubkey = ""; + + nostrClient.updateWatchHistoryList = async () => ({ ok: true }); + + try { + const result = await nostrClient.recordVideoView(pointer, { + created_at: createdAt, + }); + + assert.ok(result.ok, "recordVideoView should succeed with stubbed handlers"); + assert.equal( + publishCalls.length, + 1, + "publishViewEvent should be invoked once per recordVideoView call" + ); + + const emittedEvent = publishCalls[0]?.event || null; + assert.ok(emittedEvent, "publishViewEvent should enqueue an event for relays"); + assert.equal( + typeof emittedEvent.content, + "string", + "recordVideoView should provide serialized view content" + ); + + const payload = JSON.parse(emittedEvent.content); + assert.deepEqual( + payload, + { + target: { + type: pointer.type, + value: pointer.value, + relay: pointer.relay, + }, + created_at: createdAt, + }, + "default view content should capture pointer metadata and timestamp" + ); + + assert.equal( + result.view.event.content, + emittedEvent.content, + "serialized payload should persist on the emitted event" + ); + } finally { + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.ensureSessionActor = originalEnsureSessionActor; + nostrClient.sessionActor = originalSessionActor; + nostrClient.pubkey = originalPubkey; + nostrClient.updateWatchHistoryList = originalUpdateHistory; + console.warn = originalWarn; + window.NostrTools = originalNostrTools; + } + + const viewCounterWarnings = warnings.filter((message) => + message.includes("[viewCounter] Failed to ingest local view event") + ); + assert.equal( + viewCounterWarnings.length, + 0, + "viewCounter.ingestLocalViewEvent should not reject JSON view payloads" + ); +} + // Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays. async function testHydrationRefreshesAfterCacheTtl() { harness.reset(); @@ -568,5 +701,6 @@ await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); +await testRecordVideoViewEmitsJsonPayload(); console.log("View counter tests completed successfully."); From eae0ea47b036e70990ac0d205e7fb86da0424c4d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:14:11 -0400 Subject: [PATCH 31/82] Adjust view recording response --- js/nostr.js | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 6b280b2f..f49c37f4 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4516,12 +4516,7 @@ class NostrClient { async recordVideoView(videoPointer, options = {}) { const pointer = normalizePointerInput(videoPointer); if (!pointer) { - return { - ok: false, - error: "invalid-pointer", - view: { ok: false, error: "invalid-pointer" }, - history: { ok: false, error: "invalid-pointer" }, - }; + return { ok: false, error: "invalid-pointer" }; } const view = await this.publishViewEvent(pointer, options); @@ -4542,13 +4537,7 @@ class NostrClient { } } - const history = await this.updateWatchHistoryList(pointer); - - return { - ok: view.ok && history.ok, - view, - history, - }; + return view; } /** From 8708431083fcc83442dcb1cd71ab2fc4d79bff01 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:17:17 -0400 Subject: [PATCH 32/82] Refine playback view logging error handling --- js/app.js | 111 +++++++++++++++++++++--------------------------------- 1 file changed, 43 insertions(+), 68 deletions(-) diff --git a/js/app.js b/js/app.js index 48ec0bfd..918b03b2 100644 --- a/js/app.js +++ b/js/app.js @@ -7074,59 +7074,9 @@ class bitvidApp { state; (async () => { + let viewResult; try { - const result = await recordVideoView(thresholdPointer); - const viewOk = !!result?.view?.ok; - if (viewOk) { - const eventIdentityKey = - this.deriveViewIdentityKeyFromEvent(result?.view?.event) || - state.viewerIdentityKey || - this.getActiveViewIdentityKey(); - const keyToPersist = this.buildViewCooldownKey( - thresholdPointerKey, - eventIdentityKey - ); - if (keyToPersist) { - this.loggedViewPointerKeys.add(keyToPersist); - } - if (result?.view?.event) { - ingestLocalViewEvent({ - event: result.view.event, - pointer: thresholdPointer, - }); - } - } else if (isDevMode) { - console.warn( - "[playVideoWithFallback] View event rejected by relays:", - result?.view || result - ); - } - - let historyOk = !!result?.history?.ok; - if (!historyOk) { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] Watch history update via recordVideoView failed; retrying.", - result?.history || result - ); - } - const fallbackHistory = await updateWatchHistoryList( - thresholdPointer - ); - historyOk = !!fallbackHistory?.ok; - if (!historyOk && isDevMode) { - console.warn( - "[playVideoWithFallback] Watch history retry failed:", - fallbackHistory - ); - } - } - - if (!viewOk && isDevMode && historyOk) { - console.warn( - "[playVideoWithFallback] Watch history updated but view event was rejected." - ); - } + viewResult = await recordVideoView(thresholdPointer); } catch (error) { if (isDevMode) { console.warn( @@ -7134,23 +7084,48 @@ class bitvidApp { error ); } - try { - const fallbackHistory = await updateWatchHistoryList( - thresholdPointer + } + + const viewOk = !!viewResult?.ok; + if (viewOk) { + const eventIdentityKey = + this.deriveViewIdentityKeyFromEvent(viewResult?.event) || + state.viewerIdentityKey || + this.getActiveViewIdentityKey(); + const keyToPersist = this.buildViewCooldownKey( + thresholdPointerKey, + eventIdentityKey + ); + if (keyToPersist) { + this.loggedViewPointerKeys.add(keyToPersist); + } + if (viewResult?.event) { + ingestLocalViewEvent({ + event: viewResult.event, + pointer: thresholdPointer, + }); + } + } else if (isDevMode && viewResult) { + console.warn( + "[playVideoWithFallback] View event rejected by relays:", + viewResult + ); + } + + try { + const historyResult = await updateWatchHistoryList(thresholdPointer); + if (!historyResult?.ok && isDevMode) { + console.warn( + "[playVideoWithFallback] Watch history update failed:", + historyResult + ); + } + } catch (historyError) { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Exception while updating watch history:", + historyError ); - if (!fallbackHistory?.ok && isDevMode) { - console.warn( - "[playVideoWithFallback] Watch history retry after view exception failed:", - fallbackHistory - ); - } - } catch (historyError) { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] Exception while retrying watch history after view failure:", - historyError - ); - } } } })().catch((error) => { From 63274d4c43c532f4eff26db436b6bb8aa4d77fc8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:21:47 -0400 Subject: [PATCH 33/82] Update view counter test for publish return --- tests/view-counter.test.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 88e1ff26..06adc376 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -577,7 +577,11 @@ async function testRecordVideoViewEmitsJsonPayload() { }; nostrClient.pubkey = ""; - nostrClient.updateWatchHistoryList = async () => ({ ok: true }); + nostrClient.updateWatchHistoryList = async () => { + throw new Error( + "recordVideoView should not invoke updateWatchHistoryList during view publishes" + ); + }; try { const result = await nostrClient.recordVideoView(pointer, { @@ -614,7 +618,7 @@ async function testRecordVideoViewEmitsJsonPayload() { ); assert.equal( - result.view.event.content, + result.event.content, emittedEvent.content, "serialized payload should persist on the emitted event" ); From 28def680122af76fecf8b9c410c23fb356ff0550 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:25:35 -0400 Subject: [PATCH 34/82] Document watch history pipeline split --- docs/nostr-analytics.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index 31b9362a..1aee3e8b 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -16,10 +16,23 @@ Playback telemetry runs even when a viewer has not connected a Nostr account. Th ### Publish latency expectations -When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through `recordVideoView`. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing. +When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing. + +`recordVideoView` no longer mutates watch history—it strictly publishes analytics events. Clients that want personal playback timelines must independently call [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) once their threshold logic fires. See the [watch history pipeline](#watch-history-pipeline) for integration details. To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. ### Hydration, COUNT, and UI sync Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload. + +## Watch history pipeline + +BitVid ships a parallel watch-history system so operators can disable analytics entirely while still giving viewers a private "continue watching" shelf. [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) gathers the latest pointer plus any cached entries, normalizes them, and persists an encrypted snapshot through [`publishWatchHistorySnapshot`](../js/nostr.js#L2170-L2584). Each publish produces: + +- A monotonic index event that references the active snapshot ID and every chunk address. +- One or more encrypted chunk events containing `{ type, value, relay?, watchedAt }` tuples for each pointer. `watchedAt` is truncated to seconds and defaults to `Date.now()` when the caller omits it. + +Snapshots dedupe pointers, cap the list at the configured maximum, and are signed by the same session actor used for views. Clients should supply the same pointer structure they feed into [`recordVideoView`](../js/nostr.js#L4516-L4555)—typically `{ type: 'a', value:
, relay }`—while setting a `watchedAt` timestamp if they want to preserve historical ordering during backfills. + +Because view counting and watch history are intentionally decoupled, invoking `updateWatchHistoryList` is the caller's responsibility. Most integrations fire it immediately after `recordVideoView` confirms success, but privacy-focused deployments can disable view logging and still call the watch-history API. Cross-check the [publish latency expectations](#publish-latency-expectations) section when wiring both flows so operators understand they can operate independently. From ecd3f4026e5ce680bb80ce3e12e9521d8058b797 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:47:57 -0400 Subject: [PATCH 35/82] Restore canonical watch history snapshot format --- config/instance-config.js | 4 +-- docs/nostr-event-schemas.md | 6 +++-- js/app.js | 23 ++++++++++++++++++ js/index.js | 47 ++++++++++++++++++++++++++++++++++++ js/nostr.js | 23 +++++++++++++++++- tests/watch-history.test.mjs | 27 ++++++++++++++++++--- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index a439923c..695df656 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -63,14 +63,14 @@ export const WATCH_HISTORY_KIND = 30078; * BitVid deployments that share relays, but keep it stable once clients begin * syncing history. */ -export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history:v2:index"; +export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history"; /** * Legacy identifiers that BitVid clients should continue honoring when * fetching historical watch-history snapshots. */ export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([ - "watch-history", + "watch-history:v2:index", ]); /** diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 158359b2..716726d0 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -64,10 +64,12 @@ builders inherit the same debugging knobs. ### Watch history identifiers Watch-history snapshots now publish a dedicated index event with -`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history:v2:index`). The +`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history`). The index advertises the active snapshot id, total chunk count, and a pointer to each chunk via `a` tags so clients can fetch the encrypted payloads without -guessing identifiers. +guessing identifiers. Deployments that previously used +`watch-history:v2:index` continue to resolve thanks to the legacy identifier +list. Every chunk event uses a unique `d` tag such as `watch-history:v2//`. Legacy snapshots created before the diff --git a/js/app.js b/js/app.js index 918b03b2..2f33999e 100644 --- a/js/app.js +++ b/js/app.js @@ -4283,6 +4283,29 @@ class bitvidApp { } button.dataset.navBound = "true"; button.addEventListener("click", () => { + if (name === "history") { + let actorCandidate; + if (typeof this.pubkey === "string" && this.pubkey.trim()) { + actorCandidate = this.pubkey.trim(); + } else if ( + typeof window?.app?.pubkey === "string" && + window.app.pubkey.trim() + ) { + actorCandidate = window.app.pubkey.trim(); + } + + void nostrClient + .fetchWatchHistory(actorCandidate, { forceRefresh: true }) + .catch((error) => { + if (isDevMode) { + console.warn( + "[profileModal] Failed to prefetch watch history on navigation:", + error + ); + } + }); + } + this.selectProfilePane(name); }); }); diff --git a/js/index.js b/js/index.js index 72fd1f8e..dfb19a99 100644 --- a/js/index.js +++ b/js/index.js @@ -2,6 +2,45 @@ import "./bufferPolyfill.js"; import { trackPageView } from "./analytics.js"; +import { nostrClient } from "./nostr.js"; + +const HISTORY_PREFETCH_DEBOUNCE_MS = 250; + +function prefetchWatchHistory(options = {}) { + if (typeof nostrClient?.fetchWatchHistory !== "function") { + return; + } + + const force = options.force === true; + const now = Date.now(); + + if (typeof window !== "undefined") { + const last = + typeof window.__bitvidLastHistoryPrefetch === "number" + ? window.__bitvidLastHistoryPrefetch + : 0; + + if (!force && now - last < HISTORY_PREFETCH_DEBOUNCE_MS) { + return; + } + + window.__bitvidLastHistoryPrefetch = now; + } + + let actor; + if (typeof window?.app?.pubkey === "string" && window.app.pubkey.trim()) { + actor = window.app.pubkey.trim(); + } + + void nostrClient + .fetchWatchHistory(actor, { forceRefresh: true }) + .catch((error) => { + console.warn( + "[index] Failed to prefetch watch history during navigation:", + error + ); + }); +} const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in"; const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in"; @@ -257,6 +296,11 @@ export function setHashView(viewName) { url.searchParams.delete("v"); const newUrl = url.pathname + url.search + `#view=${viewName}`; window.history.replaceState({}, "", newUrl); + + if (typeof viewName === "string" && viewName.toLowerCase() === "history") { + prefetchWatchHistory({ force: true }); + } + handleHashChange(); } @@ -403,6 +447,9 @@ async function handleHashChange() { } const viewName = match[1]; // only the chunk before any '&' + if (typeof viewName === "string" && viewName.toLowerCase() === "history") { + prefetchWatchHistory(); + } const viewUrl = `views/${viewName}.html`; // Now dynamically load that partial, then call its init function diff --git a/js/nostr.js b/js/nostr.js index f49c37f4..05b22ec3 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -58,7 +58,28 @@ const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER = ? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase() : ""; -const WATCH_HISTORY_CHUNK_V2_PREFIX = "watch-history:v2"; +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 = diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2d8a646a..2740d781 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -109,9 +109,30 @@ function createDecryptClient(actorPubkey) { } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const WATCH_HISTORY_CHUNK_PREFIX = WATCH_HISTORY_LIST_IDENTIFIER.replace( - /[:/]index$/i, - "" +function deriveChunkPrefix(identifier) { + if (typeof identifier !== "string") { + return "watch-history:v2"; + } + + const trimmed = identifier.trim().replace(/\/+$/, ""); + if (!trimmed) { + return "watch-history:v2"; + } + + 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`; +} + +const WATCH_HISTORY_CHUNK_PREFIX = deriveChunkPrefix( + WATCH_HISTORY_LIST_IDENTIFIER ); const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX); From bea08e3b3c0374e025ffcd819486f704b6036e4c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:00:54 -0400 Subject: [PATCH 36/82] Sort watch history by most recent view --- js/historyView.js | 32 +++++++------------------ js/watchHistorySort.js | 45 ++++++++++++++++++++++++++++++++++++ tests/watch-history.test.mjs | 37 +++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 js/watchHistorySort.js diff --git a/js/historyView.js b/js/historyView.js index 3c5a2b2a..940bdad7 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -5,6 +5,11 @@ import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; import { subscriptions } from "./subscriptions.js"; import { accessControl } from "./accessControl.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; +import { + toNumber, + getWatchedAtScore, + compareByWatchedAtDesc, +} from "./watchHistorySort.js"; const DEFAULT_BATCH_SIZE = 20; const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; @@ -15,11 +20,6 @@ export const WATCH_HISTORY_EMPTY_COPY = const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…"; const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet."; -function toNumber(value, fallback) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - function getVideoAuthorIdentifiers(video) { if (!video || typeof video !== "object") { return { pubkey: "", npub: "" }; @@ -119,24 +119,6 @@ function filterAccessibleVideos(videos) { }); } -function getWatchedAtScore(video) { - if (!video || typeof video !== "object") { - return Number.NEGATIVE_INFINITY; - } - - const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); - if (Number.isFinite(watchedAt)) { - return watchedAt; - } - - const createdAtSeconds = toNumber(video?.created_at, Number.NaN); - if (Number.isFinite(createdAtSeconds)) { - return createdAtSeconds * 1000; - } - - return Number.NEGATIVE_INFINITY; -} - function getAbsoluteShareUrl(nevent) { if (!nevent) { return ""; @@ -822,7 +804,9 @@ export function createWatchHistoryRenderer(config = {}) { state.resolvedVideos.forEach((video) => ingest(dedupeMap, video)); nextVideos.forEach((video) => ingest(dedupeMap, video)); - state.resolvedVideos = Array.from(dedupeMap.values()); + state.resolvedVideos = Array.from(dedupeMap.values()).sort( + compareByWatchedAtDesc + ); applyAccessFilters(); }; diff --git a/js/watchHistorySort.js b/js/watchHistorySort.js new file mode 100644 index 00000000..3d9dcefa --- /dev/null +++ b/js/watchHistorySort.js @@ -0,0 +1,45 @@ +// js/watchHistorySort.js + +export function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function getWatchedAtScore(video) { + if (!video || typeof video !== "object") { + return Number.NEGATIVE_INFINITY; + } + + const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN); + if (Number.isFinite(watchedAt)) { + return watchedAt; + } + + const createdAtSeconds = toNumber(video?.created_at, Number.NaN); + if (Number.isFinite(createdAtSeconds)) { + return createdAtSeconds * 1000; + } + + return Number.NEGATIVE_INFINITY; +} + +export function compareByWatchedAtDesc(a, b) { + const aScore = getWatchedAtScore(a); + const bScore = getWatchedAtScore(b); + + if (aScore !== bScore) { + return bScore - aScore; + } + + const aCreated = toNumber(a?.created_at, Number.NEGATIVE_INFINITY); + const bCreated = toNumber(b?.created_at, Number.NEGATIVE_INFINITY); + + if (aCreated !== bCreated) { + return bCreated - aCreated; + } + + const aId = typeof a?.id === "string" ? a.id : ""; + const bId = typeof b?.id === "string" ? b.id : ""; + + return aId.localeCompare(bId); +} diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2740d781..f9e4f5d9 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -12,6 +12,9 @@ const { buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } = await import("../js/nostrEventSchemas.js"); +const { compareByWatchedAtDesc, getWatchedAtScore } = await import( + "../js/watchHistorySort.js" +); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -1063,4 +1066,38 @@ try { Date.now = originalDateNow; } +const unsortedVideos = [ + { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, + { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, + { id: "charlie", created_at: 1_699_999_000 }, +]; + +const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc); + +assert.deepEqual( + sortedVideos.map((video) => video.id), + ["bravo", "alpha", "charlie"], + "videos should be ordered by watchedAt descending with created_at fallback", +); + +const tieVideos = [ + { id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 }, + { id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, + { id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, +]; + +tieVideos.sort(compareByWatchedAtDesc); + +assert.equal( + getWatchedAtScore(tieVideos[0]), + getWatchedAtScore(tieVideos[1]), + "ties should preserve identical watchedAt scores", +); + +assert.deepEqual( + tieVideos.map((video) => video.id), + ["delta", "foxtrot", "echo"], + "created_at should break watchedAt ties with stable fallback", +); + console.log("watch history tests passed"); From 9416efdf1a3be7ec095f99ca53eaaf188e13e9cb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:20:11 -0400 Subject: [PATCH 37/82] Handle plaintext watch history chunks --- js/nostr.js | 147 ++++++++++++++++++++++++--- tests/watch-history.test.mjs | 188 +++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 15 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 05b22ec3..1aa43fd5 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2012,6 +2012,20 @@ class NostrClient { 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() @@ -2020,11 +2034,6 @@ class NostrClient { return fallbackPayload; } - const ciphertext = - pointerEvent && typeof pointerEvent.content === "string" - ? pointerEvent.content.trim() - : ""; - if (!ciphertext) { return fallbackPayload; } @@ -2042,11 +2051,11 @@ class NostrClient { normalizedActor, ciphertext ); - const parsed = parseWatchHistoryPayload(plaintext); - if (parsed.items.length || fallbackItems.length === 0) { - return parsed; - } - return { ...parsed, items: fallbackItems }; + return parseWatchHistoryContentWithFallback( + typeof plaintext === "string" ? plaintext.trim() : "", + fallbackItems, + fallbackPayload + ); } catch (error) { if (isDevMode) { console.warn( @@ -2078,11 +2087,11 @@ class NostrClient { normalizedActor, ciphertext ); - const parsed = parseWatchHistoryPayload(plaintext); - if (parsed.items.length || fallbackItems.length === 0) { - return parsed; - } - return { ...parsed, items: fallbackItems }; + return parseWatchHistoryContentWithFallback( + typeof plaintext === "string" ? plaintext.trim() : "", + fallbackItems, + fallbackPayload + ); } catch (error) { if (isDevMode) { console.warn( @@ -6021,6 +6030,114 @@ class NostrClient { } } +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) => diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index f9e4f5d9..fb365ee3 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1066,6 +1066,194 @@ try { Date.now = originalDateNow; } +const plaintextPayload = { + version: 2, + snapshot: "plaintext-snapshot", + chunkIndex: 0, + totalChunks: 1, + items: [ + { + type: "e", + value: "plaintext-pointer", + relay: "wss://relay.unit", + watchedAt: 1_700_000_123_000, + }, + ], +}; + +const plaintextChunkEvent = { + kind: WATCH_HISTORY_KIND, + pubkey: ACTOR, + created_at: 333, + tags: [ + ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`], + ["snapshot", plaintextPayload.snapshot], + ["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)], + ["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay], + ], + content: JSON.stringify(plaintextPayload), +}; + +const originalWindowNostr = globalThis.window.nostr; +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} +if (!globalThis.window.NostrTools.nip04) { + globalThis.window.NostrTools.nip04 = {}; +} +const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; + +try { + globalThis.window.nostr = { + nip04: { + decrypt: () => { + throw new Error("plaintext chunk should not trigger extension decrypt"); + }, + }, + }; + globalThis.window.NostrTools.nip04.decrypt = () => { + throw new Error("plaintext chunk should not trigger session decrypt"); + }; + + const plaintextClient = createDecryptClient(ACTOR); + const plaintextResult = await plaintextClient.decryptWatchHistoryEvent( + plaintextChunkEvent, + ACTOR + ); + + assert.deepEqual( + plaintextResult.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay, + watchedAt: item.watchedAt, + })), + plaintextPayload.items, + "plaintext payload should be parsed without invoking nip04 helpers", + ); + assert.equal( + plaintextResult.snapshot, + plaintextPayload.snapshot, + "plaintext payload should preserve snapshot metadata", + ); + assert.equal( + plaintextResult.chunkIndex, + plaintextPayload.chunkIndex, + "plaintext payload should preserve chunk index", + ); + assert.equal( + plaintextResult.totalChunks, + plaintextPayload.totalChunks, + "plaintext payload should preserve chunk count", + ); +} finally { + globalThis.window.nostr = originalWindowNostr; + if (originalSessionDecrypt === undefined) { + delete globalThis.window.NostrTools.nip04.decrypt; + } else { + globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt; + } +} + +const encryptedPayload = { + version: 2, + snapshot: "encrypted-snapshot", + chunkIndex: 0, + totalChunks: 1, + items: [ + { + type: "e", + value: "encrypted-pointer", + relay: "wss://relay.unit", + watchedAt: 1_700_000_456_000, + }, + ], +}; + +const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz"; + +const encryptedChunkEvent = { + kind: WATCH_HISTORY_KIND, + pubkey: ACTOR, + created_at: 444, + tags: [ + ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`], + ["encrypted", "nip04"], + ["snapshot", encryptedPayload.snapshot], + ["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)], + ["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay], + ], + content: encryptedCiphertext, +}; + +const encryptedClient = createDecryptClient(ACTOR); +encryptedClient.pubkey = ACTOR; + +const priorWindowNostr = globalThis.window.nostr; +const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; +let extensionDecryptCalls = 0; +let sessionDecryptCalls = 0; + +try { + globalThis.window.nostr = { + nip04: { + decrypt: async (pubkey, ciphertext) => { + extensionDecryptCalls += 1; + assert.equal(pubkey, ACTOR, "extension decrypt should target the actor"); + assert.equal( + ciphertext, + encryptedCiphertext, + "extension decrypt should receive the chunk ciphertext", + ); + return JSON.stringify(encryptedPayload); + }, + }, + }; + + globalThis.window.NostrTools.nip04.decrypt = async () => { + sessionDecryptCalls += 1; + return JSON.stringify(encryptedPayload); + }; + + const decryptedResult = await encryptedClient.decryptWatchHistoryEvent( + encryptedChunkEvent, + ACTOR + ); + + assert.equal( + extensionDecryptCalls, + 1, + "encrypted chunks should invoke extension nip04 decrypt", + ); + assert.equal( + sessionDecryptCalls, + 0, + "successful extension decrypt should skip session fallback", + ); + assert.deepEqual( + decryptedResult.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay, + watchedAt: item.watchedAt, + })), + encryptedPayload.items, + "encrypted payload should be reconstructed from decrypted content", + ); + assert.equal( + decryptedResult.snapshot, + encryptedPayload.snapshot, + "encrypted payload should preserve snapshot metadata", + ); +} finally { + globalThis.window.nostr = priorWindowNostr; + if (priorSessionDecrypt === undefined) { + delete globalThis.window.NostrTools.nip04.decrypt; + } else { + globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt; + } +} + const unsortedVideos = [ { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, From ea25e0a92a592419e6aac8b963805ab61a646fb7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:34:26 -0400 Subject: [PATCH 38/82] Guard relay publish listeners --- js/nostr.js | 41 ++++++++++++++++++++++++++++-------- tests/watch-history.test.mjs | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 1aa43fd5..f8750e1d 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1139,23 +1139,46 @@ function publishEventToRelay(pool, url, event) { const pub = pool.publish([url], event); if (pub && typeof pub.on === "function") { - pub.on("ok", () => { + const registerHandler = (eventName, handler) => { + try { + pub.on(eventName, handler); + return true; + } catch (error) { + if (isDevMode) { + console.warn( + `[nostr] Relay publish handle rejected ${eventName} listener:`, + error + ); + } + return false; + } + }; + + const handleSuccess = () => { clearTimeout(timeoutId); finalize(true); - }); - pub.on("seen", () => { - clearTimeout(timeoutId); - finalize(true); - }); - pub.on("failed", (reason) => { + }; + + const handleFailure = (reason) => { clearTimeout(timeoutId); const err = reason instanceof Error ? reason : new Error(String(reason || "publish failed")); finalize(false, err); - }); - return; + }; + + let handlerRegistered = false; + handlerRegistered = + registerHandler("ok", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("seen", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("failed", handleFailure) || handlerRegistered; + + if (handlerRegistered) { + return; + } } if (pub && typeof pub.then === "function") { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index fb365ee3..7198d55f 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -507,6 +507,43 @@ assert.deepEqual( "republish scheduler should receive the remaining items" ); +const NO_SEEN_ACTOR = `${ACTOR}-no-seen`; +const { + client: noSeenClient, + publishedEvents: noSeenPublishedEvents, +} = createPublishingClient(NO_SEEN_ACTOR); + +noSeenClient.pool.publish = (_urls, event) => { + noSeenPublishedEvents.push(event); + return { + on(eventName, handler) { + if (eventName === "seen") { + throw new TypeError("seen unsupported"); + } + if (eventName === "ok") { + setTimeout(handler, 0); + } else if (eventName === "failed") { + // Never invoke failed handlers in this mock; they should remain idle. + } + }, + }; +}; + +const noSeenResult = await noSeenClient.publishWatchHistorySnapshot( + NO_SEEN_ACTOR, + [{ type: "e", value: "no-seen-pointer", relay: null }] +); + +assert.equal( + noSeenResult.ok, + true, + "publish should succeed when relay handle rejects seen handlers" +); +assert( + noSeenResult.events.length > 0, + "publish should still return the emitted events when seen is unsupported" +); + delete globalThis.window.nostr; if (!globalThis.window.NostrTools.nip04) { From c87d9d96a49fb5f2cbaebef7d2fc03e6831f369d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:47:52 -0400 Subject: [PATCH 39/82] Guard admin list relay publish listeners --- js/adminListStore.js | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index 428a8e9d..1abb14bc 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -389,14 +389,40 @@ function publishToRelay(url, signedEvent, listKey) { } if (typeof pub.on === "function") { - pub.on("ok", () => finalize(true)); - pub.on("seen", () => finalize(true)); - pub.on("failed", (reason) => { + const registerHandler = (eventName, handler) => { + try { + pub.on(eventName, handler); + return true; + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Relay publish rejected ${eventName} listener:`, + error + ); + } + return false; + } + }; + + const handleFailure = (reason) => { const error = - reason instanceof Error ? reason : new Error(String(reason || "failed")); + reason instanceof Error + ? reason + : new Error(String(reason || "failed")); finalize(false, error); - }); - return; + }; + + let handlerRegistered = false; + handlerRegistered = + registerHandler("ok", () => finalize(true)) || handlerRegistered; + handlerRegistered = + registerHandler("seen", () => finalize(true)) || handlerRegistered; + handlerRegistered = + registerHandler("failed", handleFailure) || handlerRegistered; + + if (handlerRegistered) { + return; + } } if (typeof pub.then === "function") { From 1298443c0055ff72c95f419a9483c36e4272610e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:56:36 -0400 Subject: [PATCH 40/82] Handle unsupported COUNT relays --- js/nostr.js | 70 ++++++++++++++++++++++++++--- tests/nostr-count-fallback.test.mjs | 64 ++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index f8750e1d..419a989e 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1509,6 +1509,21 @@ class NostrClient { 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() { @@ -5757,6 +5772,10 @@ class NostrClient { 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."); @@ -5824,9 +5843,8 @@ class NostrClient { } else if (typeof relay.count === "function") { countPromise = relay.count(normalizedFilters, { id: requestId }); } else { - throw new Error( - `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` - ); + this.countUnsupportedRelays.add(normalizedUrl); + throw this.makeCountUnsupportedError(normalizedUrl); } const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs); @@ -5858,8 +5876,29 @@ class NostrClient { ? this.relays : RELAY_URLS; - const perRelayResults = await Promise.all( - relayList.map(async (url) => { + 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, @@ -5867,14 +5906,31 @@ class NostrClient { const count = this.extractCountValue(frame?.[2]); return { url, ok: true, frame, count }; } catch (error) { - if (isDevMode) { + 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 }; + 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) { diff --git a/tests/nostr-count-fallback.test.mjs b/tests/nostr-count-fallback.test.mjs index fc7a3487..f3490498 100644 --- a/tests/nostr-count-fallback.test.mjs +++ b/tests/nostr-count-fallback.test.mjs @@ -144,8 +144,72 @@ async function testFallbackAfterCountFailureUsesDefaultWindow() { } } +async function testUnsupportedRelaySkipsWarningsAndFallsBack() { + const client = new NostrClient(); + const unsupportedRelay = "wss://unsupported.example"; + client.relays = [unsupportedRelay]; + + let ensureRelayCalls = 0; + client.pool = { + ensureRelay: async (url) => { + ensureRelayCalls += 1; + assert.equal( + url, + unsupportedRelay, + "COUNT helper should target the configured relay" + ); + return {}; + }, + }; + + let listCalls = 0; + client.listVideoViewEvents = async () => { + listCalls += 1; + return []; + }; + + const originalWarn = console.warn; + const warnCalls = []; + console.warn = (...args) => { + warnCalls.push(args); + }; + + try { + const pointer = { type: "e", value: "unsupported-relay" }; + const result = await client.countVideoViewEvents(pointer, {}); + + assert.equal(listCalls, 1, "fallback should query list once when COUNT fails"); + assert.equal(result?.fallback, true, "unsupported relay should trigger fallback"); + assert.ok( + client.countUnsupportedRelays.has(unsupportedRelay), + "unsupported relay should be remembered for future requests" + ); + assert.equal( + warnCalls.length, + 0, + "unsupported relay error should not emit console warnings" + ); + + // Subsequent calls should skip the unsupported relay entirely. + await client.countVideoViewEvents(pointer, {}); + assert.equal( + ensureRelayCalls, + 1, + "unsupported relays should be skipped after the first failure" + ); + assert.equal( + warnCalls.length, + 0, + "no warnings should be emitted for cached unsupported relays" + ); + } finally { + console.warn = originalWarn; + } +} + await testFallbackAddsDefaultSinceWhenPoolMissing(); await testFallbackHonorsCallerOverrides(); await testFallbackAfterCountFailureUsesDefaultWindow(); +await testUnsupportedRelaySkipsWarningsAndFallsBack(); console.log("nostr COUNT fallback tests completed successfully."); From 7510ad2574c589cbc63b11d7eee46ded29c666ec Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:02:44 -0400 Subject: [PATCH 41/82] Update default WSS trackers --- js/constants.js | 3 ++- js/webtorrent.js | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/js/constants.js b/js/constants.js index d3abdaef..19780950 100644 --- a/js/constants.js +++ b/js/constants.js @@ -4,8 +4,9 @@ const DEFAULT_FLAGS = Object.freeze({ VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", - "wss://tracker.btorrent.xyz", + "wss://tracker.fastcast.nz", "wss://tracker.webtorrent.dev", + "wss://tracker.sloppyta.co:443/announce", ]), }); diff --git a/js/webtorrent.js b/js/webtorrent.js index 8e8add87..c237558a 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -1,11 +1,9 @@ //js/webtorrent.js import WebTorrent from "./webtorrent.min.js"; +import { WSS_TRACKERS } from "./constants.js"; -const DEFAULT_PROBE_TRACKERS = Object.freeze([ - "wss://tracker.btorrent.xyz", - "wss://tracker.openwebtorrent.com", -]); +const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); function decodeComponentSafe(value) { if (typeof value !== "string") { From a1505b7538bc11d04e3c6cd3d293a66bfb2c5c1d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:37:47 -0400 Subject: [PATCH 42/82] Handle watch history snapshot publish without NIP-07 signer --- js/nostr.js | 37 +++++++++++++-- tests/watch-history.test.mjs | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 419a989e..9f5827a3 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2315,6 +2315,22 @@ class NostrClient { 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.` @@ -2333,6 +2349,24 @@ class NostrClient { 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); @@ -2549,9 +2583,6 @@ class NostrClient { this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } - const baselineEntry = - existingEntry || this.watchHistoryCache.get(normalizedActor) || null; - const pointerEvent = signedIndexEvent || chunkResults.find((chunk) => chunk.success)?.event || diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 7198d55f..77f0f3ce 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -507,6 +507,96 @@ assert.deepEqual( "republish scheduler should receive the remaining items" ); +const LOGGED_ACTOR = `${ACTOR}-logged`; +const { + client: noSignerClient, + publishedEvents: noSignerPublishedEvents, + scheduledRepublishes: noSignerRepublishes, + persistedEntries: noSignerPersistedEntries, +} = createPublishingClient(LOGGED_ACTOR); + +noSignerClient.pubkey = LOGGED_ACTOR; + +const noSignerItems = [ + { + type: "e", + value: `${LOGGED_ACTOR}-event`, + watchedAt: baseWatchedAt + 123, + }, + { + type: "a", + value: `30078:${LOGGED_ACTOR}:history`, + watchedAt: baseWatchedAt + 456, + }, +]; + +const noSignerResult = await noSignerClient.publishWatchHistorySnapshot( + LOGGED_ACTOR, + noSignerItems +); + +assert.equal( + noSignerResult.ok, + false, + "logged actor without signer should report failure" +); +assert.equal( + noSignerResult.error, + "signing-unavailable", + "missing signer failure should flag availability" +); +assert.equal( + noSignerPublishedEvents.length, + 0, + "no events should publish when signer unavailable" +); +assert.equal( + noSignerRepublishes.length, + 1, + "missing signer should schedule a retry" +); +assert.equal( + noSignerRepublishes[0].actor, + LOGGED_ACTOR, + "retry should target the logged actor" +); + +const cachedLoggedEntry = noSignerClient.watchHistoryCache.get(LOGGED_ACTOR); +assert(cachedLoggedEntry, "missing signer should keep snapshot cached"); +assert.equal( + cachedLoggedEntry.items.length, + noSignerItems.length, + "cached entry should retain merged items" +); +assert.equal( + noSignerPersistedEntries.length, + 1, + "missing signer should persist cached payload" +); +assert.deepEqual( + cachedLoggedEntry.items.map((item) => ({ + type: item.type, + value: item.value, + })), + noSignerResult.items.map((item) => ({ + type: item.type, + value: item.value, + })), + "returned items should mirror cached payload" +); + +const scheduledLoggedKeys = new Set( + noSignerRepublishes[0].items.map((item) => `${item.type}:${item.value}`) +); +const cachedLoggedKeys = new Set( + cachedLoggedEntry.items.map((item) => `${item.type}:${item.value}`) +); +assert.deepEqual( + scheduledLoggedKeys, + cachedLoggedKeys, + "scheduled retry should include cached items" +); + const NO_SEEN_ACTOR = `${ACTOR}-no-seen`; const { client: noSeenClient, From 61c8b3a8ee42048afa47daa3b7dc34a9b7ecbe73 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:50:08 -0400 Subject: [PATCH 43/82] Handle missing signer when decrypting watch history --- js/nostr.js | 78 ++++++++++++++++++++++++++++++++++-- tests/watch-history.test.mjs | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 9f5827a3..4165aeb1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1778,6 +1778,9 @@ class NostrClient { 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))); @@ -1801,6 +1804,11 @@ class NostrClient { 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 @@ -2077,12 +2085,39 @@ class NostrClient { } const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; + 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 && - window?.nostr?.nip04?.decrypt + hasExtensionDecrypt ) { try { const plaintext = await window.nostr.nip04.decrypt( @@ -3932,7 +3967,11 @@ class NostrClient { indexEvent: null, chunkAddresses: new Set(), expectedChunkIdentifiers: new Set(), + needsSignerRetry: new Set(), }; + if (!(bucket.needsSignerRetry instanceof Set)) { + bucket.needsSignerRetry = new Set(); + } bucket.chunks.set(chunkIndex, { event, @@ -3941,6 +3980,7 @@ class NostrClient { snapshot: snapshotId, chunkIndex, totalChunks, + needsSignerRetry: !!payload?.needsSignerRetry, }, created_at: typeof event.created_at === "number" ? event.created_at : 0, }); @@ -3951,6 +3991,11 @@ class NostrClient { 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); @@ -3971,7 +4016,11 @@ class NostrClient { indexEvent: null, chunkAddresses: new Set(), expectedChunkIdentifiers: new Set(), + needsSignerRetry: new Set(), }; + if (!(bucket.needsSignerRetry instanceof Set)) { + bucket.needsSignerRetry = new Set(); + } if (meta.event) { const createdAt = @@ -4060,6 +4109,9 @@ class NostrClient { expectedChunkIdentifiers: bucket.expectedChunkIdentifiers instanceof Set ? new Set(bucket.expectedChunkIdentifiers) : new Set(), + needsSignerRetry: bucket.needsSignerRetry instanceof Set + ? new Set(bucket.needsSignerRetry) + : new Set(), }; if (!fallbackSnapshot) { @@ -4124,6 +4176,12 @@ class NostrClient { entry.chunkEvents.clear(); } + if (!(entry.chunkNeedsSigner instanceof Set)) { + entry.chunkNeedsSigner = new Set(); + } else { + entry.chunkNeedsSigner.clear(); + } + const chunkPointerItemsForEntry = []; const chunkPointerKeys = new Set(); let indexPointerForEntry = null; @@ -4178,8 +4236,22 @@ class NostrClient { continue; } const clonedChunkEvent = cloneEventForCache(chunk.event); - if (clonedChunkEvent) { + 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); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 77f0f3ce..649abf1a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1381,6 +1381,79 @@ try { } } +const missingSignerClient = createDecryptClient(LOGGED_ACTOR); +missingSignerClient.pubkey = LOGGED_ACTOR; +missingSignerClient.sessionActor = null; + +let missingSignerEnsureCalls = 0; +missingSignerClient.ensureSessionActor = async () => { + missingSignerEnsureCalls += 1; + return LOGGED_ACTOR; +}; + +const priorMissingSignerNostr = globalThis.window.nostr; +const priorMissingSignerSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; +let missingSignerSessionDecryptCalls = 0; + +try { + delete globalThis.window.nostr; + globalThis.window.NostrTools.nip04.decrypt = async () => { + missingSignerSessionDecryptCalls += 1; + return JSON.stringify(encryptedPayload); + }; + + const missingSignerChunkEvent = { + ...encryptedChunkEvent, + pubkey: LOGGED_ACTOR, + id: "missing-signer-event", + }; + + const missingSignerResult = await missingSignerClient.decryptWatchHistoryEvent( + missingSignerChunkEvent, + LOGGED_ACTOR + ); + + assert.equal( + missingSignerEnsureCalls, + 0, + "missing signer should not trigger session actor ensure" + ); + assert.equal( + missingSignerSessionDecryptCalls, + 0, + "missing signer should skip session decrypt fallback" + ); + assert.equal( + missingSignerResult.needsSignerRetry, + true, + "missing signer fallback should mark retry flag" + ); + assert.deepEqual( + missingSignerResult.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + encryptedPayload.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + "missing signer fallback should preserve pointer metadata" + ); +} finally { + if (priorMissingSignerNostr === undefined) { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = priorMissingSignerNostr; + } + if (priorMissingSignerSessionDecrypt === undefined) { + delete globalThis.window.NostrTools.nip04.decrypt; + } else { + globalThis.window.NostrTools.nip04.decrypt = priorMissingSignerSessionDecrypt; + } +} + const unsortedVideos = [ { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, From 8ff22e1e81ac8c4fc877f5be8d09d3567fcdab20 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:02:55 -0400 Subject: [PATCH 44/82] Increase watch history fetch limit and expand tests --- config/instance-config.js | 2 +- js/nostr.js | 28 +++++++- tests/watch-history.test.mjs | 127 +++++++++++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 695df656..38440d43 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -110,7 +110,7 @@ export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000; * the maximum number of chunks you expect a client to publish in one snapshot * so the UI can stitch the full list back together. */ -export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; +export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 64; /** * How long clients should cache watch-history snapshots in localStorage. diff --git a/js/nostr.js b/js/nostr.js index 4165aeb1..ea7df804 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3823,11 +3823,37 @@ class NostrClient { 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 + snapshotIds.size ? snapshotIds.size * 2 : 0, + newestSnapshotChunkTarget, + newestSnapshotIdentifierTarget ); if (chunkIdentifiers.size) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 649abf1a..0d58b000 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; const { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_KIND, + WATCH_HISTORY_FETCH_EVENT_LIMIT, } = await import("../js/config.js"); const { NOTE_TYPES, @@ -264,7 +265,7 @@ const { client: publishingClient, publishedEvents, payloads } = createPublishingClient(ACTOR); publishingClient.watchHistoryPayloadMaxBytes = 400; -publishingClient.watchHistoryFetchEventLimit = 10; +publishingClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const baseWatchedAt = 1_700_000_000_000; const longPointers = Array.from({ length: 8 }, (_, index) => { @@ -417,7 +418,7 @@ const { chunkFailureClient.watchHistoryPayloadMaxBytes = publishingClient.watchHistoryPayloadMaxBytes; -chunkFailureClient.watchHistoryFetchEventLimit = 10; +chunkFailureClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot( CHUNK_FAILURE_ACTOR, @@ -648,7 +649,7 @@ globalThis.window.NostrTools.nip04.decrypt = async ( const decryptClient = createDecryptClient(ACTOR); decryptClient.relays = ["wss://unit.test"]; -decryptClient.watchHistoryFetchEventLimit = 10; +decryptClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; decryptClient.pool = { list: async () => publishedEvents, }; @@ -689,7 +690,7 @@ const snapshotId = headEvent.tags.find( const freshClient = createDecryptClient(ACTOR); freshClient.relays = ["wss://unit.test"]; -freshClient.watchHistoryFetchEventLimit = 10; +freshClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const observedFilters = []; freshClient.pool = { @@ -801,6 +802,124 @@ assert( "follow-up query should target chunk identifiers or snapshot" ); +const OVERFLOW_ACTOR = `${ACTOR}-overflow`; +const overflowSnapshotId = "overflow-snapshot"; +const overflowTotalChunks = 18; +const overflowBaseCreatedAt = 1_700_555_000; + +const overflowPointers = Array.from({ length: overflowTotalChunks }, (_, index) => { + const relay = index % 3 === 0 ? "wss://overflow.unit" : null; + return { + type: index % 2 === 0 ? "e" : "a", + value: + index % 2 === 0 + ? `overflow-event-${index}` + : `${WATCH_HISTORY_KIND}:${OVERFLOW_ACTOR}:overflow-${index}`, + relay, + watchedAt: baseWatchedAt + index * 45_000, + }; +}); + +const overflowChunkEvents = overflowPointers.map((pointer, index) => { + const chunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/${overflowSnapshotId}/${index}`; + const pointerTag = [pointer.type, pointer.value]; + if (pointer.relay) { + pointerTag.push(pointer.relay); + } + const payload = { + version: 2, + snapshot: overflowSnapshotId, + chunkIndex: index, + totalChunks: overflowTotalChunks, + items: [pointer], + }; + const event = buildWatchHistoryChunkEvent({ + pubkey: OVERFLOW_ACTOR, + created_at: overflowBaseCreatedAt + (overflowTotalChunks - index), + chunkIdentifier, + snapshotId: overflowSnapshotId, + chunkIndex: index, + totalChunks: overflowTotalChunks, + pointerTags: [pointerTag], + chunkAddresses: [], + content: JSON.stringify(payload), + }); + event.id = window.NostrTools.getEventHash(event); + event.sig = `sig-overflow-chunk-${index}`; + return event; +}); + +const overflowIndexEvent = buildWatchHistoryIndexEvent({ + pubkey: OVERFLOW_ACTOR, + created_at: overflowBaseCreatedAt + overflowTotalChunks + 5, + snapshotId: overflowSnapshotId, + totalChunks: overflowTotalChunks, + chunkAddresses: [], +}); +overflowIndexEvent.id = window.NostrTools.getEventHash(overflowIndexEvent); +overflowIndexEvent.sig = "sig-overflow-index"; + +const overflowClient = createDecryptClient(OVERFLOW_ACTOR); +overflowClient.relays = ["wss://overflow.test"]; +// Simulate deployments that kept the fetch limit near the historical default. +overflowClient.watchHistoryFetchEventLimit = 12; + +const overflowChunkLimits = []; +overflowClient.pool = { + list: async (_relays, filters) => { + const firstFilter = filters[0] || {}; + const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : []; + if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { + return [overflowIndexEvent]; + } + + filters.forEach((filter) => { + if (Number.isFinite(filter?.limit)) { + overflowChunkLimits.push(filter.limit); + } + }); + + const requestedSnapshots = new Set(); + filters.forEach((filter) => { + if (Array.isArray(filter?.["#snapshot"])) { + filter["#snapshot"].forEach((value) => { + if (typeof value === "string" && value) { + requestedSnapshots.add(value); + } + }); + } + }); + + if (!requestedSnapshots.size) { + return []; + } + + return overflowChunkEvents.filter((event) => { + const snapshotTag = event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "snapshot" + ); + return snapshotTag && requestedSnapshots.has(snapshotTag[1]); + }); + }, +}; + +const overflowFetched = await overflowClient.fetchWatchHistory(OVERFLOW_ACTOR); + +assert.equal( + overflowFetched.items.length, + overflowTotalChunks, + "fetch should hydrate snapshots larger than the legacy limit" +); + +const maxOverflowLimit = overflowChunkLimits.length + ? Math.max(...overflowChunkLimits) + : 0; + +assert( + maxOverflowLimit >= overflowTotalChunks + 1, + "chunk fetch should cover all chunks plus the newest index event" +); + const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = createPublishingClient(MONOTONIC_ACTOR); From 672c7cb052485ab443f6b6b621514b5e2650cdde Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:25:38 -0400 Subject: [PATCH 45/82] Guard watch history publish against missing seen listener --- js/nostr.js | 19 +++++++++++++++++-- tests/watch-history.test.mjs | 8 ++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index ea7df804..6d241a3d 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1171,8 +1171,23 @@ function publishEventToRelay(pool, url, event) { let handlerRegistered = false; handlerRegistered = registerHandler("ok", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("seen", handleSuccess) || handlerRegistered; + + const supportsSeenEvent = (() => { + if (!pub || typeof pub !== "object") { + return false; + } + const candidateArrays = [ + pub.seen, + pub._seen, + pub.listeners?.seen, + ]; + return candidateArrays.some((value) => Array.isArray(value)); + })(); + + if (supportsSeenEvent) { + handlerRegistered = + registerHandler("seen", handleSuccess) || handlerRegistered; + } handlerRegistered = registerHandler("failed", handleFailure) || handlerRegistered; diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 0d58b000..1d266e90 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -604,11 +604,14 @@ const { publishedEvents: noSeenPublishedEvents, } = createPublishingClient(NO_SEEN_ACTOR); +let noSeenHandlerRequested = false; + noSeenClient.pool.publish = (_urls, event) => { noSeenPublishedEvents.push(event); return { on(eventName, handler) { if (eventName === "seen") { + noSeenHandlerRequested = true; throw new TypeError("seen unsupported"); } if (eventName === "ok") { @@ -634,6 +637,11 @@ assert( noSeenResult.events.length > 0, "publish should still return the emitted events when seen is unsupported" ); +assert.equal( + noSeenHandlerRequested, + false, + "publish should not attempt to register a seen handler when relay omits support" +); delete globalThis.window.nostr; From b6dc562a3e719b45bb260f5f14aff4c737d3c77b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:30:18 -0400 Subject: [PATCH 46/82] Revert "Guard watch history publish when relays omit seen listener" --- js/nostr.js | 19 ++----------------- tests/watch-history.test.mjs | 8 -------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 6d241a3d..ea7df804 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1171,23 +1171,8 @@ function publishEventToRelay(pool, url, event) { let handlerRegistered = false; handlerRegistered = registerHandler("ok", handleSuccess) || handlerRegistered; - - const supportsSeenEvent = (() => { - if (!pub || typeof pub !== "object") { - return false; - } - const candidateArrays = [ - pub.seen, - pub._seen, - pub.listeners?.seen, - ]; - return candidateArrays.some((value) => Array.isArray(value)); - })(); - - if (supportsSeenEvent) { - handlerRegistered = - registerHandler("seen", handleSuccess) || handlerRegistered; - } + handlerRegistered = + registerHandler("seen", handleSuccess) || handlerRegistered; handlerRegistered = registerHandler("failed", handleFailure) || handlerRegistered; diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 1d266e90..0d58b000 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -604,14 +604,11 @@ const { publishedEvents: noSeenPublishedEvents, } = createPublishingClient(NO_SEEN_ACTOR); -let noSeenHandlerRequested = false; - noSeenClient.pool.publish = (_urls, event) => { noSeenPublishedEvents.push(event); return { on(eventName, handler) { if (eventName === "seen") { - noSeenHandlerRequested = true; throw new TypeError("seen unsupported"); } if (eventName === "ok") { @@ -637,11 +634,6 @@ assert( noSeenResult.events.length > 0, "publish should still return the emitted events when seen is unsupported" ); -assert.equal( - noSeenHandlerRequested, - false, - "publish should not attempt to register a seen handler when relay omits support" -); delete globalThis.window.nostr; From 61aa55e70b00be07d49bbb7ad0ca08b23362785d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:45:09 -0400 Subject: [PATCH 47/82] Replace bundled nostr-tools with esm module --- index.html | 40 +- js/libs/nostr.bundle.js | 8676 --------------------------------------- 2 files changed, 17 insertions(+), 8699 deletions(-) delete mode 100644 js/libs/nostr.bundle.js diff --git a/index.html b/index.html index 515568d1..fba2d26a 100644 --- a/index.html +++ b/index.html @@ -198,34 +198,28 @@
- diff --git a/js/libs/nostr.bundle.js b/js/libs/nostr.bundle.js deleted file mode 100644 index 4342b2b6..00000000 --- a/js/libs/nostr.bundle.js +++ /dev/null @@ -1,8676 +0,0 @@ -"use strict"; -var NostrTools = (() => { - var __create = Object.create; - var __defProp = Object.defineProperty; - var __getOwnPropDesc = Object.getOwnPropertyDescriptor; - var __getOwnPropNames = Object.getOwnPropertyNames; - var __getProtoOf = Object.getPrototypeOf; - var __hasOwnProp = Object.prototype.hasOwnProperty; - var __esm = (fn, res) => function __init() { - return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; - }; - var __commonJS = (cb, mod3) => function __require() { - return mod3 || (0, cb[__getOwnPropNames(cb)[0]])((mod3 = { exports: {} }).exports, mod3), mod3.exports; - }; - var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; - }; - var __toESM = (mod3, isNodeMode, target) => (target = mod3 != null ? __create(__getProtoOf(mod3)) : {}, __copyProps( - isNodeMode || !mod3 || !mod3.__esModule ? __defProp(target, "default", { value: mod3, enumerable: true }) : target, - mod3 - )); - var __toCommonJS = (mod3) => __copyProps(__defProp({}, "__esModule", { value: true }), mod3); - - // - var init_define_process = __esm({ - ""() { - } - }); - - // (disabled):crypto - var require_crypto = __commonJS({ - "(disabled):crypto"() { - init_define_process(); - } - }); - - // node_modules/@scure/bip39/wordlists/english.js - var require_english = __commonJS({ - "node_modules/@scure/bip39/wordlists/english.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.wordlist = void 0; - exports.wordlist = `abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo`.split("\n"); - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/_assert.js - var require_assert = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/_assert.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.output = exports.exists = exports.hash = exports.bytes = exports.bool = exports.number = void 0; - function number2(n) { - if (!Number.isSafeInteger(n) || n < 0) - throw new Error(`Wrong positive integer: ${n}`); - } - exports.number = number2; - function bool2(b) { - if (typeof b !== "boolean") - throw new Error(`Expected boolean, not ${b}`); - } - exports.bool = bool2; - function bytes2(b, ...lengths) { - if (!(b instanceof Uint8Array)) - throw new TypeError("Expected Uint8Array"); - if (lengths.length > 0 && !lengths.includes(b.length)) - throw new TypeError(`Expected Uint8Array of length ${lengths}, not of length=${b.length}`); - } - exports.bytes = bytes2; - function hash2(hash3) { - if (typeof hash3 !== "function" || typeof hash3.create !== "function") - throw new Error("Hash should be wrapped by utils.wrapConstructor"); - number2(hash3.outputLen); - number2(hash3.blockLen); - } - exports.hash = hash2; - function exists2(instance, checkFinished = true) { - if (instance.destroyed) - throw new Error("Hash instance has been destroyed"); - if (checkFinished && instance.finished) - throw new Error("Hash#digest() has already been called"); - } - exports.exists = exists2; - function output2(out, instance) { - bytes2(out); - const min = instance.outputLen; - if (out.length < min) { - throw new Error(`digestInto() expects output buffer of length at least ${min}`); - } - } - exports.output = output2; - var assert2 = { - number: number2, - bool: bool2, - bytes: bytes2, - hash: hash2, - exists: exists2, - output: output2 - }; - exports.default = assert2; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/cryptoBrowser.js - var require_cryptoBrowser = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/cryptoBrowser.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.crypto = void 0; - exports.crypto = { - node: void 0, - web: typeof self === "object" && "crypto" in self ? self.crypto : void 0 - }; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/utils.js - var require_utils = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/utils.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.randomBytes = exports.wrapConstructorWithOpts = exports.wrapConstructor = exports.checkOpts = exports.Hash = exports.concatBytes = exports.toBytes = exports.utf8ToBytes = exports.asyncLoop = exports.nextTick = exports.hexToBytes = exports.bytesToHex = exports.isLE = exports.rotr = exports.createView = exports.u32 = exports.u8 = void 0; - var crypto_1 = require_cryptoBrowser(); - var u8 = (arr) => new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); - exports.u8 = u8; - var u32 = (arr) => new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); - exports.u32 = u32; - var createView3 = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); - exports.createView = createView3; - var rotr3 = (word, shift) => word << 32 - shift | word >>> shift; - exports.rotr = rotr3; - exports.isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; - if (!exports.isLE) - throw new Error("Non little-endian hardware is not supported"); - var hexes5 = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, "0")); - function bytesToHex4(uint8a) { - if (!(uint8a instanceof Uint8Array)) - throw new Error("Uint8Array expected"); - let hex2 = ""; - for (let i = 0; i < uint8a.length; i++) { - hex2 += hexes5[uint8a[i]]; - } - return hex2; - } - exports.bytesToHex = bytesToHex4; - function hexToBytes4(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToBytes: expected string, got " + typeof hex2); - } - if (hex2.length % 2) - throw new Error("hexToBytes: received invalid unpadded hex"); - const array = new Uint8Array(hex2.length / 2); - for (let i = 0; i < array.length; i++) { - const j = i * 2; - const hexByte = hex2.slice(j, j + 2); - const byte = Number.parseInt(hexByte, 16); - if (Number.isNaN(byte) || byte < 0) - throw new Error("Invalid byte sequence"); - array[i] = byte; - } - return array; - } - exports.hexToBytes = hexToBytes4; - var nextTick2 = async () => { - }; - exports.nextTick = nextTick2; - async function asyncLoop(iters, tick, cb) { - let ts = Date.now(); - for (let i = 0; i < iters; i++) { - cb(i); - const diff = Date.now() - ts; - if (diff >= 0 && diff < tick) - continue; - await (0, exports.nextTick)(); - ts += diff; - } - } - exports.asyncLoop = asyncLoop; - function utf8ToBytes3(str) { - if (typeof str !== "string") { - throw new TypeError(`utf8ToBytes expected string, got ${typeof str}`); - } - return new TextEncoder().encode(str); - } - exports.utf8ToBytes = utf8ToBytes3; - function toBytes3(data) { - if (typeof data === "string") - data = utf8ToBytes3(data); - if (!(data instanceof Uint8Array)) - throw new TypeError(`Expected input type is Uint8Array (got ${typeof data})`); - return data; - } - exports.toBytes = toBytes3; - function concatBytes4(...arrays) { - if (!arrays.every((a) => a instanceof Uint8Array)) - throw new Error("Uint8Array list expected"); - if (arrays.length === 1) - return arrays[0]; - const length = arrays.reduce((a, arr) => a + arr.length, 0); - const result = new Uint8Array(length); - for (let i = 0, pad = 0; i < arrays.length; i++) { - const arr = arrays[i]; - result.set(arr, pad); - pad += arr.length; - } - return result; - } - exports.concatBytes = concatBytes4; - var Hash3 = class { - clone() { - return this._cloneInto(); - } - }; - exports.Hash = Hash3; - var isPlainObject = (obj) => Object.prototype.toString.call(obj) === "[object Object]" && obj.constructor === Object; - function checkOpts(defaults, opts) { - if (opts !== void 0 && (typeof opts !== "object" || !isPlainObject(opts))) - throw new TypeError("Options should be object or undefined"); - const merged = Object.assign(defaults, opts); - return merged; - } - exports.checkOpts = checkOpts; - function wrapConstructor3(hashConstructor) { - const hashC = (message) => hashConstructor().update(toBytes3(message)).digest(); - const tmp = hashConstructor(); - hashC.outputLen = tmp.outputLen; - hashC.blockLen = tmp.blockLen; - hashC.create = () => hashConstructor(); - return hashC; - } - exports.wrapConstructor = wrapConstructor3; - function wrapConstructorWithOpts(hashCons) { - const hashC = (msg, opts) => hashCons(opts).update(toBytes3(msg)).digest(); - const tmp = hashCons({}); - hashC.outputLen = tmp.outputLen; - hashC.blockLen = tmp.blockLen; - hashC.create = (opts) => hashCons(opts); - return hashC; - } - exports.wrapConstructorWithOpts = wrapConstructorWithOpts; - function randomBytes2(bytesLength = 32) { - if (crypto_1.crypto.web) { - return crypto_1.crypto.web.getRandomValues(new Uint8Array(bytesLength)); - } else if (crypto_1.crypto.node) { - return new Uint8Array(crypto_1.crypto.node.randomBytes(bytesLength).buffer); - } else { - throw new Error("The environment doesn't have randomBytes function"); - } - } - exports.randomBytes = randomBytes2; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/hmac.js - var require_hmac = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/hmac.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.hmac = void 0; - var _assert_js_1 = require_assert(); - var utils_js_1 = require_utils(); - var HMAC3 = class extends utils_js_1.Hash { - constructor(hash2, _key) { - super(); - this.finished = false; - this.destroyed = false; - _assert_js_1.default.hash(hash2); - const key = (0, utils_js_1.toBytes)(_key); - this.iHash = hash2.create(); - if (typeof this.iHash.update !== "function") - throw new TypeError("Expected instance of class which extends utils.Hash"); - this.blockLen = this.iHash.blockLen; - this.outputLen = this.iHash.outputLen; - const blockLen = this.blockLen; - const pad = new Uint8Array(blockLen); - pad.set(key.length > blockLen ? hash2.create().update(key).digest() : key); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54; - this.iHash.update(pad); - this.oHash = hash2.create(); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54 ^ 92; - this.oHash.update(pad); - pad.fill(0); - } - update(buf) { - _assert_js_1.default.exists(this); - this.iHash.update(buf); - return this; - } - digestInto(out) { - _assert_js_1.default.exists(this); - _assert_js_1.default.bytes(out, this.outputLen); - this.finished = true; - this.iHash.digestInto(out); - this.oHash.update(out); - this.oHash.digestInto(out); - this.destroy(); - } - digest() { - const out = new Uint8Array(this.oHash.outputLen); - this.digestInto(out); - return out; - } - _cloneInto(to) { - to || (to = Object.create(Object.getPrototypeOf(this), {})); - const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this; - to = to; - to.finished = finished; - to.destroyed = destroyed; - to.blockLen = blockLen; - to.outputLen = outputLen; - to.oHash = oHash._cloneInto(to.oHash); - to.iHash = iHash._cloneInto(to.iHash); - return to; - } - destroy() { - this.destroyed = true; - this.oHash.destroy(); - this.iHash.destroy(); - } - }; - var hmac3 = (hash2, key, message) => new HMAC3(hash2, key).update(message).digest(); - exports.hmac = hmac3; - exports.hmac.create = (hash2, key) => new HMAC3(hash2, key); - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/pbkdf2.js - var require_pbkdf2 = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/pbkdf2.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.pbkdf2Async = exports.pbkdf2 = void 0; - var _assert_js_1 = require_assert(); - var hmac_js_1 = require_hmac(); - var utils_js_1 = require_utils(); - function pbkdf2Init(hash2, _password, _salt, _opts) { - _assert_js_1.default.hash(hash2); - const opts = (0, utils_js_1.checkOpts)({ dkLen: 32, asyncTick: 10 }, _opts); - const { c, dkLen, asyncTick } = opts; - _assert_js_1.default.number(c); - _assert_js_1.default.number(dkLen); - _assert_js_1.default.number(asyncTick); - if (c < 1) - throw new Error("PBKDF2: iterations (c) should be >= 1"); - const password = (0, utils_js_1.toBytes)(_password); - const salt = (0, utils_js_1.toBytes)(_salt); - const DK = new Uint8Array(dkLen); - const PRF = hmac_js_1.hmac.create(hash2, password); - const PRFSalt = PRF._cloneInto().update(salt); - return { c, dkLen, asyncTick, DK, PRF, PRFSalt }; - } - function pbkdf2Output(PRF, PRFSalt, DK, prfW, u) { - PRF.destroy(); - PRFSalt.destroy(); - if (prfW) - prfW.destroy(); - u.fill(0); - return DK; - } - function pbkdf2(hash2, password, salt, opts) { - const { c, dkLen, DK, PRF, PRFSalt } = pbkdf2Init(hash2, password, salt, opts); - let prfW; - const arr = new Uint8Array(4); - const view = (0, utils_js_1.createView)(arr); - const u = new Uint8Array(PRF.outputLen); - for (let ti = 1, pos = 0; pos < dkLen; ti++, pos += PRF.outputLen) { - const Ti = DK.subarray(pos, pos + PRF.outputLen); - view.setInt32(0, ti, false); - (prfW = PRFSalt._cloneInto(prfW)).update(arr).digestInto(u); - Ti.set(u.subarray(0, Ti.length)); - for (let ui = 1; ui < c; ui++) { - PRF._cloneInto(prfW).update(u).digestInto(u); - for (let i = 0; i < Ti.length; i++) - Ti[i] ^= u[i]; - } - } - return pbkdf2Output(PRF, PRFSalt, DK, prfW, u); - } - exports.pbkdf2 = pbkdf2; - async function pbkdf2Async(hash2, password, salt, opts) { - const { c, dkLen, asyncTick, DK, PRF, PRFSalt } = pbkdf2Init(hash2, password, salt, opts); - let prfW; - const arr = new Uint8Array(4); - const view = (0, utils_js_1.createView)(arr); - const u = new Uint8Array(PRF.outputLen); - for (let ti = 1, pos = 0; pos < dkLen; ti++, pos += PRF.outputLen) { - const Ti = DK.subarray(pos, pos + PRF.outputLen); - view.setInt32(0, ti, false); - (prfW = PRFSalt._cloneInto(prfW)).update(arr).digestInto(u); - Ti.set(u.subarray(0, Ti.length)); - await (0, utils_js_1.asyncLoop)(c - 1, asyncTick, (i) => { - PRF._cloneInto(prfW).update(u).digestInto(u); - for (let i2 = 0; i2 < Ti.length; i2++) - Ti[i2] ^= u[i2]; - }); - } - return pbkdf2Output(PRF, PRFSalt, DK, prfW, u); - } - exports.pbkdf2Async = pbkdf2Async; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/_sha2.js - var require_sha2 = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/_sha2.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.SHA2 = void 0; - var _assert_js_1 = require_assert(); - var utils_js_1 = require_utils(); - function setBigUint643(view, byteOffset, value, isLE3) { - if (typeof view.setBigUint64 === "function") - return view.setBigUint64(byteOffset, value, isLE3); - const _32n2 = BigInt(32); - const _u32_max = BigInt(4294967295); - const wh = Number(value >> _32n2 & _u32_max); - const wl = Number(value & _u32_max); - const h = isLE3 ? 4 : 0; - const l = isLE3 ? 0 : 4; - view.setUint32(byteOffset + h, wh, isLE3); - view.setUint32(byteOffset + l, wl, isLE3); - } - var SHA23 = class extends utils_js_1.Hash { - constructor(blockLen, outputLen, padOffset, isLE3) { - super(); - this.blockLen = blockLen; - this.outputLen = outputLen; - this.padOffset = padOffset; - this.isLE = isLE3; - this.finished = false; - this.length = 0; - this.pos = 0; - this.destroyed = false; - this.buffer = new Uint8Array(blockLen); - this.view = (0, utils_js_1.createView)(this.buffer); - } - update(data) { - _assert_js_1.default.exists(this); - const { view, buffer, blockLen } = this; - data = (0, utils_js_1.toBytes)(data); - const len = data.length; - for (let pos = 0; pos < len; ) { - const take = Math.min(blockLen - this.pos, len - pos); - if (take === blockLen) { - const dataView = (0, utils_js_1.createView)(data); - for (; blockLen <= len - pos; pos += blockLen) - this.process(dataView, pos); - continue; - } - buffer.set(data.subarray(pos, pos + take), this.pos); - this.pos += take; - pos += take; - if (this.pos === blockLen) { - this.process(view, 0); - this.pos = 0; - } - } - this.length += data.length; - this.roundClean(); - return this; - } - digestInto(out) { - _assert_js_1.default.exists(this); - _assert_js_1.default.output(out, this); - this.finished = true; - const { buffer, view, blockLen, isLE: isLE3 } = this; - let { pos } = this; - buffer[pos++] = 128; - this.buffer.subarray(pos).fill(0); - if (this.padOffset > blockLen - pos) { - this.process(view, 0); - pos = 0; - } - for (let i = pos; i < blockLen; i++) - buffer[i] = 0; - setBigUint643(view, blockLen - 8, BigInt(this.length * 8), isLE3); - this.process(view, 0); - const oview = (0, utils_js_1.createView)(out); - const len = this.outputLen; - if (len % 4) - throw new Error("_sha2: outputLen should be aligned to 32bit"); - const outLen = len / 4; - const state = this.get(); - if (outLen > state.length) - throw new Error("_sha2: outputLen bigger than state"); - for (let i = 0; i < outLen; i++) - oview.setUint32(4 * i, state[i], isLE3); - } - digest() { - const { buffer, outputLen } = this; - this.digestInto(buffer); - const res = buffer.slice(0, outputLen); - this.destroy(); - return res; - } - _cloneInto(to) { - to || (to = new this.constructor()); - to.set(...this.get()); - const { blockLen, buffer, length, finished, destroyed, pos } = this; - to.length = length; - to.pos = pos; - to.finished = finished; - to.destroyed = destroyed; - if (length % blockLen) - to.buffer.set(buffer); - return to; - } - }; - exports.SHA2 = SHA23; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/sha256.js - var require_sha256 = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/sha256.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.sha224 = exports.sha256 = void 0; - var _sha2_js_1 = require_sha2(); - var utils_js_1 = require_utils(); - var Chi3 = (a, b, c) => a & b ^ ~a & c; - var Maj3 = (a, b, c) => a & b ^ a & c ^ b & c; - var SHA256_K3 = new Uint32Array([ - 1116352408, - 1899447441, - 3049323471, - 3921009573, - 961987163, - 1508970993, - 2453635748, - 2870763221, - 3624381080, - 310598401, - 607225278, - 1426881987, - 1925078388, - 2162078206, - 2614888103, - 3248222580, - 3835390401, - 4022224774, - 264347078, - 604807628, - 770255983, - 1249150122, - 1555081692, - 1996064986, - 2554220882, - 2821834349, - 2952996808, - 3210313671, - 3336571891, - 3584528711, - 113926993, - 338241895, - 666307205, - 773529912, - 1294757372, - 1396182291, - 1695183700, - 1986661051, - 2177026350, - 2456956037, - 2730485921, - 2820302411, - 3259730800, - 3345764771, - 3516065817, - 3600352804, - 4094571909, - 275423344, - 430227734, - 506948616, - 659060556, - 883997877, - 958139571, - 1322822218, - 1537002063, - 1747873779, - 1955562222, - 2024104815, - 2227730452, - 2361852424, - 2428436474, - 2756734187, - 3204031479, - 3329325298 - ]); - var IV3 = new Uint32Array([ - 1779033703, - 3144134277, - 1013904242, - 2773480762, - 1359893119, - 2600822924, - 528734635, - 1541459225 - ]); - var SHA256_W3 = new Uint32Array(64); - var SHA2563 = class extends _sha2_js_1.SHA2 { - constructor() { - super(64, 32, 8, false); - this.A = IV3[0] | 0; - this.B = IV3[1] | 0; - this.C = IV3[2] | 0; - this.D = IV3[3] | 0; - this.E = IV3[4] | 0; - this.F = IV3[5] | 0; - this.G = IV3[6] | 0; - this.H = IV3[7] | 0; - } - get() { - const { A, B, C, D, E, F, G, H } = this; - return [A, B, C, D, E, F, G, H]; - } - set(A, B, C, D, E, F, G, H) { - this.A = A | 0; - this.B = B | 0; - this.C = C | 0; - this.D = D | 0; - this.E = E | 0; - this.F = F | 0; - this.G = G | 0; - this.H = H | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) - SHA256_W3[i] = view.getUint32(offset, false); - for (let i = 16; i < 64; i++) { - const W15 = SHA256_W3[i - 15]; - const W2 = SHA256_W3[i - 2]; - const s0 = (0, utils_js_1.rotr)(W15, 7) ^ (0, utils_js_1.rotr)(W15, 18) ^ W15 >>> 3; - const s1 = (0, utils_js_1.rotr)(W2, 17) ^ (0, utils_js_1.rotr)(W2, 19) ^ W2 >>> 10; - SHA256_W3[i] = s1 + SHA256_W3[i - 7] + s0 + SHA256_W3[i - 16] | 0; - } - let { A, B, C, D, E, F, G, H } = this; - for (let i = 0; i < 64; i++) { - const sigma1 = (0, utils_js_1.rotr)(E, 6) ^ (0, utils_js_1.rotr)(E, 11) ^ (0, utils_js_1.rotr)(E, 25); - const T1 = H + sigma1 + Chi3(E, F, G) + SHA256_K3[i] + SHA256_W3[i] | 0; - const sigma0 = (0, utils_js_1.rotr)(A, 2) ^ (0, utils_js_1.rotr)(A, 13) ^ (0, utils_js_1.rotr)(A, 22); - const T2 = sigma0 + Maj3(A, B, C) | 0; - H = G; - G = F; - F = E; - E = D + T1 | 0; - D = C; - C = B; - B = A; - A = T1 + T2 | 0; - } - A = A + this.A | 0; - B = B + this.B | 0; - C = C + this.C | 0; - D = D + this.D | 0; - E = E + this.E | 0; - F = F + this.F | 0; - G = G + this.G | 0; - H = H + this.H | 0; - this.set(A, B, C, D, E, F, G, H); - } - roundClean() { - SHA256_W3.fill(0); - } - destroy() { - this.set(0, 0, 0, 0, 0, 0, 0, 0); - this.buffer.fill(0); - } - }; - var SHA2242 = class extends SHA2563 { - constructor() { - super(); - this.A = 3238371032 | 0; - this.B = 914150663 | 0; - this.C = 812702999 | 0; - this.D = 4144912697 | 0; - this.E = 4290775857 | 0; - this.F = 1750603025 | 0; - this.G = 1694076839 | 0; - this.H = 3204075428 | 0; - this.outputLen = 28; - } - }; - exports.sha256 = (0, utils_js_1.wrapConstructor)(() => new SHA2563()); - exports.sha224 = (0, utils_js_1.wrapConstructor)(() => new SHA2242()); - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/_u64.js - var require_u64 = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/_u64.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.add = exports.toBig = exports.split = exports.fromBig = void 0; - var U32_MASK642 = BigInt(2 ** 32 - 1); - var _32n2 = BigInt(32); - function fromBig2(n, le = false) { - if (le) - return { h: Number(n & U32_MASK642), l: Number(n >> _32n2 & U32_MASK642) }; - return { h: Number(n >> _32n2 & U32_MASK642) | 0, l: Number(n & U32_MASK642) | 0 }; - } - exports.fromBig = fromBig2; - function split2(lst, le = false) { - let Ah = new Uint32Array(lst.length); - let Al = new Uint32Array(lst.length); - for (let i = 0; i < lst.length; i++) { - const { h, l } = fromBig2(lst[i], le); - [Ah[i], Al[i]] = [h, l]; - } - return [Ah, Al]; - } - exports.split = split2; - var toBig2 = (h, l) => BigInt(h >>> 0) << _32n2 | BigInt(l >>> 0); - exports.toBig = toBig2; - var shrSH2 = (h, l, s) => h >>> s; - var shrSL2 = (h, l, s) => h << 32 - s | l >>> s; - var rotrSH2 = (h, l, s) => h >>> s | l << 32 - s; - var rotrSL2 = (h, l, s) => h << 32 - s | l >>> s; - var rotrBH2 = (h, l, s) => h << 64 - s | l >>> s - 32; - var rotrBL2 = (h, l, s) => h >>> s - 32 | l << 64 - s; - var rotr32H2 = (h, l) => l; - var rotr32L2 = (h, l) => h; - var rotlSH2 = (h, l, s) => h << s | l >>> 32 - s; - var rotlSL2 = (h, l, s) => l << s | h >>> 32 - s; - var rotlBH2 = (h, l, s) => l << s - 32 | h >>> 64 - s; - var rotlBL2 = (h, l, s) => h << s - 32 | l >>> 64 - s; - function add2(Ah, Al, Bh, Bl) { - const l = (Al >>> 0) + (Bl >>> 0); - return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 }; - } - exports.add = add2; - var add3L2 = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0); - var add3H2 = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0; - var add4L2 = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0); - var add4H2 = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0; - var add5L2 = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0); - var add5H2 = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0; - var u642 = { - fromBig: fromBig2, - split: split2, - toBig: exports.toBig, - shrSH: shrSH2, - shrSL: shrSL2, - rotrSH: rotrSH2, - rotrSL: rotrSL2, - rotrBH: rotrBH2, - rotrBL: rotrBL2, - rotr32H: rotr32H2, - rotr32L: rotr32L2, - rotlSH: rotlSH2, - rotlSL: rotlSL2, - rotlBH: rotlBH2, - rotlBL: rotlBL2, - add: add2, - add3L: add3L2, - add3H: add3H2, - add4L: add4L2, - add4H: add4H2, - add5H: add5H2, - add5L: add5L2 - }; - exports.default = u642; - } - }); - - // node_modules/@scure/bip39/node_modules/@noble/hashes/sha512.js - var require_sha512 = __commonJS({ - "node_modules/@scure/bip39/node_modules/@noble/hashes/sha512.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.sha384 = exports.sha512_256 = exports.sha512_224 = exports.sha512 = exports.SHA512 = void 0; - var _sha2_js_1 = require_sha2(); - var _u64_js_1 = require_u64(); - var utils_js_1 = require_utils(); - var [SHA512_Kh2, SHA512_Kl2] = _u64_js_1.default.split([ - "0x428a2f98d728ae22", - "0x7137449123ef65cd", - "0xb5c0fbcfec4d3b2f", - "0xe9b5dba58189dbbc", - "0x3956c25bf348b538", - "0x59f111f1b605d019", - "0x923f82a4af194f9b", - "0xab1c5ed5da6d8118", - "0xd807aa98a3030242", - "0x12835b0145706fbe", - "0x243185be4ee4b28c", - "0x550c7dc3d5ffb4e2", - "0x72be5d74f27b896f", - "0x80deb1fe3b1696b1", - "0x9bdc06a725c71235", - "0xc19bf174cf692694", - "0xe49b69c19ef14ad2", - "0xefbe4786384f25e3", - "0x0fc19dc68b8cd5b5", - "0x240ca1cc77ac9c65", - "0x2de92c6f592b0275", - "0x4a7484aa6ea6e483", - "0x5cb0a9dcbd41fbd4", - "0x76f988da831153b5", - "0x983e5152ee66dfab", - "0xa831c66d2db43210", - "0xb00327c898fb213f", - "0xbf597fc7beef0ee4", - "0xc6e00bf33da88fc2", - "0xd5a79147930aa725", - "0x06ca6351e003826f", - "0x142929670a0e6e70", - "0x27b70a8546d22ffc", - "0x2e1b21385c26c926", - "0x4d2c6dfc5ac42aed", - "0x53380d139d95b3df", - "0x650a73548baf63de", - "0x766a0abb3c77b2a8", - "0x81c2c92e47edaee6", - "0x92722c851482353b", - "0xa2bfe8a14cf10364", - "0xa81a664bbc423001", - "0xc24b8b70d0f89791", - "0xc76c51a30654be30", - "0xd192e819d6ef5218", - "0xd69906245565a910", - "0xf40e35855771202a", - "0x106aa07032bbd1b8", - "0x19a4c116b8d2d0c8", - "0x1e376c085141ab53", - "0x2748774cdf8eeb99", - "0x34b0bcb5e19b48a8", - "0x391c0cb3c5c95a63", - "0x4ed8aa4ae3418acb", - "0x5b9cca4f7763e373", - "0x682e6ff3d6b2b8a3", - "0x748f82ee5defb2fc", - "0x78a5636f43172f60", - "0x84c87814a1f0ab72", - "0x8cc702081a6439ec", - "0x90befffa23631e28", - "0xa4506cebde82bde9", - "0xbef9a3f7b2c67915", - "0xc67178f2e372532b", - "0xca273eceea26619c", - "0xd186b8c721c0c207", - "0xeada7dd6cde0eb1e", - "0xf57d4f7fee6ed178", - "0x06f067aa72176fba", - "0x0a637dc5a2c898a6", - "0x113f9804bef90dae", - "0x1b710b35131c471b", - "0x28db77f523047d84", - "0x32caab7b40c72493", - "0x3c9ebe0a15c9bebc", - "0x431d67c49c100d4c", - "0x4cc5d4becb3e42b6", - "0x597f299cfc657e2a", - "0x5fcb6fab3ad6faec", - "0x6c44198c4a475817" - ].map((n) => BigInt(n))); - var SHA512_W_H2 = new Uint32Array(80); - var SHA512_W_L2 = new Uint32Array(80); - var SHA5122 = class extends _sha2_js_1.SHA2 { - constructor() { - super(128, 64, 16, false); - this.Ah = 1779033703 | 0; - this.Al = 4089235720 | 0; - this.Bh = 3144134277 | 0; - this.Bl = 2227873595 | 0; - this.Ch = 1013904242 | 0; - this.Cl = 4271175723 | 0; - this.Dh = 2773480762 | 0; - this.Dl = 1595750129 | 0; - this.Eh = 1359893119 | 0; - this.El = 2917565137 | 0; - this.Fh = 2600822924 | 0; - this.Fl = 725511199 | 0; - this.Gh = 528734635 | 0; - this.Gl = 4215389547 | 0; - this.Hh = 1541459225 | 0; - this.Hl = 327033209 | 0; - } - get() { - const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this; - return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl]; - } - set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) { - this.Ah = Ah | 0; - this.Al = Al | 0; - this.Bh = Bh | 0; - this.Bl = Bl | 0; - this.Ch = Ch | 0; - this.Cl = Cl | 0; - this.Dh = Dh | 0; - this.Dl = Dl | 0; - this.Eh = Eh | 0; - this.El = El | 0; - this.Fh = Fh | 0; - this.Fl = Fl | 0; - this.Gh = Gh | 0; - this.Gl = Gl | 0; - this.Hh = Hh | 0; - this.Hl = Hl | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) { - SHA512_W_H2[i] = view.getUint32(offset); - SHA512_W_L2[i] = view.getUint32(offset += 4); - } - for (let i = 16; i < 80; i++) { - const W15h = SHA512_W_H2[i - 15] | 0; - const W15l = SHA512_W_L2[i - 15] | 0; - const s0h = _u64_js_1.default.rotrSH(W15h, W15l, 1) ^ _u64_js_1.default.rotrSH(W15h, W15l, 8) ^ _u64_js_1.default.shrSH(W15h, W15l, 7); - const s0l = _u64_js_1.default.rotrSL(W15h, W15l, 1) ^ _u64_js_1.default.rotrSL(W15h, W15l, 8) ^ _u64_js_1.default.shrSL(W15h, W15l, 7); - const W2h = SHA512_W_H2[i - 2] | 0; - const W2l = SHA512_W_L2[i - 2] | 0; - const s1h = _u64_js_1.default.rotrSH(W2h, W2l, 19) ^ _u64_js_1.default.rotrBH(W2h, W2l, 61) ^ _u64_js_1.default.shrSH(W2h, W2l, 6); - const s1l = _u64_js_1.default.rotrSL(W2h, W2l, 19) ^ _u64_js_1.default.rotrBL(W2h, W2l, 61) ^ _u64_js_1.default.shrSL(W2h, W2l, 6); - const SUMl = _u64_js_1.default.add4L(s0l, s1l, SHA512_W_L2[i - 7], SHA512_W_L2[i - 16]); - const SUMh = _u64_js_1.default.add4H(SUMl, s0h, s1h, SHA512_W_H2[i - 7], SHA512_W_H2[i - 16]); - SHA512_W_H2[i] = SUMh | 0; - SHA512_W_L2[i] = SUMl | 0; - } - let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this; - for (let i = 0; i < 80; i++) { - const sigma1h = _u64_js_1.default.rotrSH(Eh, El, 14) ^ _u64_js_1.default.rotrSH(Eh, El, 18) ^ _u64_js_1.default.rotrBH(Eh, El, 41); - const sigma1l = _u64_js_1.default.rotrSL(Eh, El, 14) ^ _u64_js_1.default.rotrSL(Eh, El, 18) ^ _u64_js_1.default.rotrBL(Eh, El, 41); - const CHIh = Eh & Fh ^ ~Eh & Gh; - const CHIl = El & Fl ^ ~El & Gl; - const T1ll = _u64_js_1.default.add5L(Hl, sigma1l, CHIl, SHA512_Kl2[i], SHA512_W_L2[i]); - const T1h = _u64_js_1.default.add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh2[i], SHA512_W_H2[i]); - const T1l = T1ll | 0; - const sigma0h = _u64_js_1.default.rotrSH(Ah, Al, 28) ^ _u64_js_1.default.rotrBH(Ah, Al, 34) ^ _u64_js_1.default.rotrBH(Ah, Al, 39); - const sigma0l = _u64_js_1.default.rotrSL(Ah, Al, 28) ^ _u64_js_1.default.rotrBL(Ah, Al, 34) ^ _u64_js_1.default.rotrBL(Ah, Al, 39); - const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch; - const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl; - Hh = Gh | 0; - Hl = Gl | 0; - Gh = Fh | 0; - Gl = Fl | 0; - Fh = Eh | 0; - Fl = El | 0; - ({ h: Eh, l: El } = _u64_js_1.default.add(Dh | 0, Dl | 0, T1h | 0, T1l | 0)); - Dh = Ch | 0; - Dl = Cl | 0; - Ch = Bh | 0; - Cl = Bl | 0; - Bh = Ah | 0; - Bl = Al | 0; - const All = _u64_js_1.default.add3L(T1l, sigma0l, MAJl); - Ah = _u64_js_1.default.add3H(All, T1h, sigma0h, MAJh); - Al = All | 0; - } - ({ h: Ah, l: Al } = _u64_js_1.default.add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0)); - ({ h: Bh, l: Bl } = _u64_js_1.default.add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0)); - ({ h: Ch, l: Cl } = _u64_js_1.default.add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0)); - ({ h: Dh, l: Dl } = _u64_js_1.default.add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0)); - ({ h: Eh, l: El } = _u64_js_1.default.add(this.Eh | 0, this.El | 0, Eh | 0, El | 0)); - ({ h: Fh, l: Fl } = _u64_js_1.default.add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0)); - ({ h: Gh, l: Gl } = _u64_js_1.default.add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0)); - ({ h: Hh, l: Hl } = _u64_js_1.default.add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0)); - this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl); - } - roundClean() { - SHA512_W_H2.fill(0); - SHA512_W_L2.fill(0); - } - destroy() { - this.buffer.fill(0); - this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - } - }; - exports.SHA512 = SHA5122; - var SHA512_2242 = class extends SHA5122 { - constructor() { - super(); - this.Ah = 2352822216 | 0; - this.Al = 424955298 | 0; - this.Bh = 1944164710 | 0; - this.Bl = 2312950998 | 0; - this.Ch = 502970286 | 0; - this.Cl = 855612546 | 0; - this.Dh = 1738396948 | 0; - this.Dl = 1479516111 | 0; - this.Eh = 258812777 | 0; - this.El = 2077511080 | 0; - this.Fh = 2011393907 | 0; - this.Fl = 79989058 | 0; - this.Gh = 1067287976 | 0; - this.Gl = 1780299464 | 0; - this.Hh = 286451373 | 0; - this.Hl = 2446758561 | 0; - this.outputLen = 28; - } - }; - var SHA512_2562 = class extends SHA5122 { - constructor() { - super(); - this.Ah = 573645204 | 0; - this.Al = 4230739756 | 0; - this.Bh = 2673172387 | 0; - this.Bl = 3360449730 | 0; - this.Ch = 596883563 | 0; - this.Cl = 1867755857 | 0; - this.Dh = 2520282905 | 0; - this.Dl = 1497426621 | 0; - this.Eh = 2519219938 | 0; - this.El = 2827943907 | 0; - this.Fh = 3193839141 | 0; - this.Fl = 1401305490 | 0; - this.Gh = 721525244 | 0; - this.Gl = 746961066 | 0; - this.Hh = 246885852 | 0; - this.Hl = 2177182882 | 0; - this.outputLen = 32; - } - }; - var SHA3842 = class extends SHA5122 { - constructor() { - super(); - this.Ah = 3418070365 | 0; - this.Al = 3238371032 | 0; - this.Bh = 1654270250 | 0; - this.Bl = 914150663 | 0; - this.Ch = 2438529370 | 0; - this.Cl = 812702999 | 0; - this.Dh = 355462360 | 0; - this.Dl = 4144912697 | 0; - this.Eh = 1731405415 | 0; - this.El = 4290775857 | 0; - this.Fh = 2394180231 | 0; - this.Fl = 1750603025 | 0; - this.Gh = 3675008525 | 0; - this.Gl = 1694076839 | 0; - this.Hh = 1203062813 | 0; - this.Hl = 3204075428 | 0; - this.outputLen = 48; - } - }; - exports.sha512 = (0, utils_js_1.wrapConstructor)(() => new SHA5122()); - exports.sha512_224 = (0, utils_js_1.wrapConstructor)(() => new SHA512_2242()); - exports.sha512_256 = (0, utils_js_1.wrapConstructor)(() => new SHA512_2562()); - exports.sha384 = (0, utils_js_1.wrapConstructor)(() => new SHA3842()); - } - }); - - // node_modules/@scure/base/lib/index.js - var require_lib = __commonJS({ - "node_modules/@scure/base/lib/index.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.bytes = exports.stringToBytes = exports.str = exports.bytesToString = exports.hex = exports.utf8 = exports.bech32m = exports.bech32 = exports.base58check = exports.base58xmr = exports.base58xrp = exports.base58flickr = exports.base58 = exports.base64url = exports.base64 = exports.base32crockford = exports.base32hex = exports.base32 = exports.base16 = exports.utils = exports.assertNumber = void 0; - function assertNumber3(n) { - if (!Number.isSafeInteger(n)) - throw new Error(`Wrong integer: ${n}`); - } - exports.assertNumber = assertNumber3; - function chain2(...args) { - const wrap = (a, b) => (c) => a(b(c)); - const encode = Array.from(args).reverse().reduce((acc, i) => acc ? wrap(acc, i.encode) : i.encode, void 0); - const decode2 = args.reduce((acc, i) => acc ? wrap(acc, i.decode) : i.decode, void 0); - return { encode, decode: decode2 }; - } - function alphabet2(alphabet3) { - return { - encode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("alphabet.encode input should be an array of numbers"); - return digits.map((i) => { - assertNumber3(i); - if (i < 0 || i >= alphabet3.length) - throw new Error(`Digit index outside alphabet: ${i} (alphabet: ${alphabet3.length})`); - return alphabet3[i]; - }); - }, - decode: (input) => { - if (!Array.isArray(input) || input.length && typeof input[0] !== "string") - throw new Error("alphabet.decode input should be array of strings"); - return input.map((letter) => { - if (typeof letter !== "string") - throw new Error(`alphabet.decode: not string element=${letter}`); - const index = alphabet3.indexOf(letter); - if (index === -1) - throw new Error(`Unknown letter: "${letter}". Allowed: ${alphabet3}`); - return index; - }); - } - }; - } - function join2(separator = "") { - if (typeof separator !== "string") - throw new Error("join separator should be string"); - return { - encode: (from) => { - if (!Array.isArray(from) || from.length && typeof from[0] !== "string") - throw new Error("join.encode input should be array of strings"); - for (let i of from) - if (typeof i !== "string") - throw new Error(`join.encode: non-string input=${i}`); - return from.join(separator); - }, - decode: (to) => { - if (typeof to !== "string") - throw new Error("join.decode input should be string"); - return to.split(separator); - } - }; - } - function padding2(bits, chr = "=") { - assertNumber3(bits); - if (typeof chr !== "string") - throw new Error("padding chr should be string"); - return { - encode(data) { - if (!Array.isArray(data) || data.length && typeof data[0] !== "string") - throw new Error("padding.encode input should be array of strings"); - for (let i of data) - if (typeof i !== "string") - throw new Error(`padding.encode: non-string input=${i}`); - while (data.length * bits % 8) - data.push(chr); - return data; - }, - decode(input) { - if (!Array.isArray(input) || input.length && typeof input[0] !== "string") - throw new Error("padding.encode input should be array of strings"); - for (let i of input) - if (typeof i !== "string") - throw new Error(`padding.decode: non-string input=${i}`); - let end = input.length; - if (end * bits % 8) - throw new Error("Invalid padding: string should have whole number of bytes"); - for (; end > 0 && input[end - 1] === chr; end--) { - if (!((end - 1) * bits % 8)) - throw new Error("Invalid padding: string has too much padding"); - } - return input.slice(0, end); - } - }; - } - function normalize2(fn) { - if (typeof fn !== "function") - throw new Error("normalize fn should be function"); - return { encode: (from) => from, decode: (to) => fn(to) }; - } - function convertRadix3(data, from, to) { - if (from < 2) - throw new Error(`convertRadix: wrong from=${from}, base cannot be less than 2`); - if (to < 2) - throw new Error(`convertRadix: wrong to=${to}, base cannot be less than 2`); - if (!Array.isArray(data)) - throw new Error("convertRadix: data should be array"); - if (!data.length) - return []; - let pos = 0; - const res = []; - const digits = Array.from(data); - digits.forEach((d) => { - assertNumber3(d); - if (d < 0 || d >= from) - throw new Error(`Wrong integer: ${d}`); - }); - while (true) { - let carry = 0; - let done = true; - for (let i = pos; i < digits.length; i++) { - const digit = digits[i]; - const digitBase = from * carry + digit; - if (!Number.isSafeInteger(digitBase) || from * carry / from !== carry || digitBase - digit !== from * carry) { - throw new Error("convertRadix: carry overflow"); - } - carry = digitBase % to; - digits[i] = Math.floor(digitBase / to); - if (!Number.isSafeInteger(digits[i]) || digits[i] * to + carry !== digitBase) - throw new Error("convertRadix: carry overflow"); - if (!done) - continue; - else if (!digits[i]) - pos = i; - else - done = false; - } - res.push(carry); - if (done) - break; - } - for (let i = 0; i < data.length - 1 && data[i] === 0; i++) - res.push(0); - return res.reverse(); - } - var gcd2 = (a, b) => !b ? a : gcd2(b, a % b); - var radix2carry2 = (from, to) => from + (to - gcd2(from, to)); - function convertRadix22(data, from, to, padding3) { - if (!Array.isArray(data)) - throw new Error("convertRadix2: data should be array"); - if (from <= 0 || from > 32) - throw new Error(`convertRadix2: wrong from=${from}`); - if (to <= 0 || to > 32) - throw new Error(`convertRadix2: wrong to=${to}`); - if (radix2carry2(from, to) > 32) { - throw new Error(`convertRadix2: carry overflow from=${from} to=${to} carryBits=${radix2carry2(from, to)}`); - } - let carry = 0; - let pos = 0; - const mask = 2 ** to - 1; - const res = []; - for (const n of data) { - assertNumber3(n); - if (n >= 2 ** from) - throw new Error(`convertRadix2: invalid data word=${n} from=${from}`); - carry = carry << from | n; - if (pos + from > 32) - throw new Error(`convertRadix2: carry overflow pos=${pos} from=${from}`); - pos += from; - for (; pos >= to; pos -= to) - res.push((carry >> pos - to & mask) >>> 0); - carry &= 2 ** pos - 1; - } - carry = carry << to - pos & mask; - if (!padding3 && pos >= from) - throw new Error("Excess padding"); - if (!padding3 && carry) - throw new Error(`Non-zero padding: ${carry}`); - if (padding3 && pos > 0) - res.push(carry >>> 0); - return res; - } - function radix3(num) { - assertNumber3(num); - return { - encode: (bytes2) => { - if (!(bytes2 instanceof Uint8Array)) - throw new Error("radix.encode input should be Uint8Array"); - return convertRadix3(Array.from(bytes2), 2 ** 8, num); - }, - decode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("radix.decode input should be array of strings"); - return Uint8Array.from(convertRadix3(digits, num, 2 ** 8)); - } - }; - } - function radix22(bits, revPadding = false) { - assertNumber3(bits); - if (bits <= 0 || bits > 32) - throw new Error("radix2: bits should be in (0..32]"); - if (radix2carry2(8, bits) > 32 || radix2carry2(bits, 8) > 32) - throw new Error("radix2: carry overflow"); - return { - encode: (bytes2) => { - if (!(bytes2 instanceof Uint8Array)) - throw new Error("radix2.encode input should be Uint8Array"); - return convertRadix22(Array.from(bytes2), 8, bits, !revPadding); - }, - decode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("radix2.decode input should be array of strings"); - return Uint8Array.from(convertRadix22(digits, bits, 8, revPadding)); - } - }; - } - function unsafeWrapper2(fn) { - if (typeof fn !== "function") - throw new Error("unsafeWrapper fn should be function"); - return function(...args) { - try { - return fn.apply(null, args); - } catch (e) { - } - }; - } - function checksum2(len, fn) { - assertNumber3(len); - if (typeof fn !== "function") - throw new Error("checksum fn should be function"); - return { - encode(data) { - if (!(data instanceof Uint8Array)) - throw new Error("checksum.encode: input should be Uint8Array"); - const checksum3 = fn(data).slice(0, len); - const res = new Uint8Array(data.length + len); - res.set(data); - res.set(checksum3, data.length); - return res; - }, - decode(data) { - if (!(data instanceof Uint8Array)) - throw new Error("checksum.decode: input should be Uint8Array"); - const payload = data.slice(0, -len); - const newChecksum = fn(payload).slice(0, len); - const oldChecksum = data.slice(-len); - for (let i = 0; i < len; i++) - if (newChecksum[i] !== oldChecksum[i]) - throw new Error("Invalid checksum"); - return payload; - } - }; - } - exports.utils = { alphabet: alphabet2, chain: chain2, checksum: checksum2, radix: radix3, radix2: radix22, join: join2, padding: padding2 }; - exports.base16 = chain2(radix22(4), alphabet2("0123456789ABCDEF"), join2("")); - exports.base32 = chain2(radix22(5), alphabet2("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), padding2(5), join2("")); - exports.base32hex = chain2(radix22(5), alphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUV"), padding2(5), join2("")); - exports.base32crockford = chain2(radix22(5), alphabet2("0123456789ABCDEFGHJKMNPQRSTVWXYZ"), join2(""), normalize2((s) => s.toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"))); - exports.base64 = chain2(radix22(6), alphabet2("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), padding2(6), join2("")); - exports.base64url = chain2(radix22(6), alphabet2("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), padding2(6), join2("")); - var genBase582 = (abc) => chain2(radix3(58), alphabet2(abc), join2("")); - exports.base58 = genBase582("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"); - exports.base58flickr = genBase582("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"); - exports.base58xrp = genBase582("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"); - var XMR_BLOCK_LEN2 = [0, 2, 3, 5, 6, 7, 9, 10, 11]; - exports.base58xmr = { - encode(data) { - let res = ""; - for (let i = 0; i < data.length; i += 8) { - const block = data.subarray(i, i + 8); - res += exports.base58.encode(block).padStart(XMR_BLOCK_LEN2[block.length], "1"); - } - return res; - }, - decode(str) { - let res = []; - for (let i = 0; i < str.length; i += 11) { - const slice = str.slice(i, i + 11); - const blockLen = XMR_BLOCK_LEN2.indexOf(slice.length); - const block = exports.base58.decode(slice); - for (let j = 0; j < block.length - blockLen; j++) { - if (block[j] !== 0) - throw new Error("base58xmr: wrong padding"); - } - res = res.concat(Array.from(block.slice(block.length - blockLen))); - } - return Uint8Array.from(res); - } - }; - var base58check3 = (sha2563) => chain2(checksum2(4, (data) => sha2563(sha2563(data))), exports.base58); - exports.base58check = base58check3; - var BECH_ALPHABET2 = chain2(alphabet2("qpzry9x8gf2tvdw0s3jn54khce6mua7l"), join2("")); - var POLYMOD_GENERATORS2 = [996825010, 642813549, 513874426, 1027748829, 705979059]; - function bech32Polymod2(pre) { - const b = pre >> 25; - let chk = (pre & 33554431) << 5; - for (let i = 0; i < POLYMOD_GENERATORS2.length; i++) { - if ((b >> i & 1) === 1) - chk ^= POLYMOD_GENERATORS2[i]; - } - return chk; - } - function bechChecksum2(prefix, words, encodingConst = 1) { - const len = prefix.length; - let chk = 1; - for (let i = 0; i < len; i++) { - const c = prefix.charCodeAt(i); - if (c < 33 || c > 126) - throw new Error(`Invalid prefix (${prefix})`); - chk = bech32Polymod2(chk) ^ c >> 5; - } - chk = bech32Polymod2(chk); - for (let i = 0; i < len; i++) - chk = bech32Polymod2(chk) ^ prefix.charCodeAt(i) & 31; - for (let v of words) - chk = bech32Polymod2(chk) ^ v; - for (let i = 0; i < 6; i++) - chk = bech32Polymod2(chk); - chk ^= encodingConst; - return BECH_ALPHABET2.encode(convertRadix22([chk % 2 ** 30], 30, 5, false)); - } - function genBech322(encoding) { - const ENCODING_CONST = encoding === "bech32" ? 1 : 734539939; - const _words = radix22(5); - const fromWords = _words.decode; - const toWords = _words.encode; - const fromWordsUnsafe = unsafeWrapper2(fromWords); - function encode(prefix, words, limit = 90) { - if (typeof prefix !== "string") - throw new Error(`bech32.encode prefix should be string, not ${typeof prefix}`); - if (!Array.isArray(words) || words.length && typeof words[0] !== "number") - throw new Error(`bech32.encode words should be array of numbers, not ${typeof words}`); - const actualLength = prefix.length + 7 + words.length; - if (limit !== false && actualLength > limit) - throw new TypeError(`Length ${actualLength} exceeds limit ${limit}`); - prefix = prefix.toLowerCase(); - return `${prefix}1${BECH_ALPHABET2.encode(words)}${bechChecksum2(prefix, words, ENCODING_CONST)}`; - } - function decode2(str, limit = 90) { - if (typeof str !== "string") - throw new Error(`bech32.decode input should be string, not ${typeof str}`); - if (str.length < 8 || limit !== false && str.length > limit) - throw new TypeError(`Wrong string length: ${str.length} (${str}). Expected (8..${limit})`); - const lowered = str.toLowerCase(); - if (str !== lowered && str !== str.toUpperCase()) - throw new Error(`String must be lowercase or uppercase`); - str = lowered; - const sepIndex = str.lastIndexOf("1"); - if (sepIndex === 0 || sepIndex === -1) - throw new Error(`Letter "1" must be present between prefix and data only`); - const prefix = str.slice(0, sepIndex); - const _words2 = str.slice(sepIndex + 1); - if (_words2.length < 6) - throw new Error("Data must be at least 6 characters long"); - const words = BECH_ALPHABET2.decode(_words2).slice(0, -6); - const sum = bechChecksum2(prefix, words, ENCODING_CONST); - if (!_words2.endsWith(sum)) - throw new Error(`Invalid checksum in ${str}: expected "${sum}"`); - return { prefix, words }; - } - const decodeUnsafe = unsafeWrapper2(decode2); - function decodeToBytes(str) { - const { prefix, words } = decode2(str, false); - return { prefix, words, bytes: fromWords(words) }; - } - return { encode, decode: decode2, decodeToBytes, decodeUnsafe, fromWords, fromWordsUnsafe, toWords }; - } - exports.bech32 = genBech322("bech32"); - exports.bech32m = genBech322("bech32m"); - exports.utf8 = { - encode: (data) => new TextDecoder().decode(data), - decode: (str) => new TextEncoder().encode(str) - }; - exports.hex = chain2(radix22(4), alphabet2("0123456789abcdef"), join2(""), normalize2((s) => { - if (typeof s !== "string" || s.length % 2) - throw new TypeError(`hex.decode: expected string, got ${typeof s} with length ${s.length}`); - return s.toLowerCase(); - })); - var CODERS2 = { - utf8: exports.utf8, - hex: exports.hex, - base16: exports.base16, - base32: exports.base32, - base64: exports.base64, - base64url: exports.base64url, - base58: exports.base58, - base58xmr: exports.base58xmr - }; - var coderTypeError2 = `Invalid encoding type. Available types: ${Object.keys(CODERS2).join(", ")}`; - var bytesToString = (type, bytes2) => { - if (typeof type !== "string" || !CODERS2.hasOwnProperty(type)) - throw new TypeError(coderTypeError2); - if (!(bytes2 instanceof Uint8Array)) - throw new TypeError("bytesToString() expects Uint8Array"); - return CODERS2[type].encode(bytes2); - }; - exports.bytesToString = bytesToString; - exports.str = exports.bytesToString; - var stringToBytes = (type, str) => { - if (!CODERS2.hasOwnProperty(type)) - throw new TypeError(coderTypeError2); - if (typeof str !== "string") - throw new TypeError("stringToBytes() expects string"); - return CODERS2[type].decode(str); - }; - exports.stringToBytes = stringToBytes; - exports.bytes = exports.stringToBytes; - } - }); - - // node_modules/@scure/bip39/index.js - var require_bip39 = __commonJS({ - "node_modules/@scure/bip39/index.js"(exports) { - "use strict"; - init_define_process(); - Object.defineProperty(exports, "__esModule", { value: true }); - exports.mnemonicToSeedSync = exports.mnemonicToSeed = exports.validateMnemonic = exports.entropyToMnemonic = exports.mnemonicToEntropy = exports.generateMnemonic = void 0; - var _assert_1 = require_assert(); - var pbkdf2_1 = require_pbkdf2(); - var sha256_1 = require_sha256(); - var sha512_1 = require_sha512(); - var utils_1 = require_utils(); - var base_1 = require_lib(); - var isJapanese = (wordlist2) => wordlist2[0] === "\u3042\u3044\u3053\u304F\u3057\u3093"; - function nfkd(str) { - if (typeof str !== "string") - throw new TypeError(`Invalid mnemonic type: ${typeof str}`); - return str.normalize("NFKD"); - } - function normalize2(str) { - const norm = nfkd(str); - const words = norm.split(" "); - if (![12, 15, 18, 21, 24].includes(words.length)) - throw new Error("Invalid mnemonic"); - return { nfkd: norm, words }; - } - function assertEntropy(entropy) { - _assert_1.default.bytes(entropy, 16, 20, 24, 28, 32); - } - function generateMnemonic2(wordlist2, strength = 128) { - _assert_1.default.number(strength); - if (strength % 32 !== 0 || strength > 256) - throw new TypeError("Invalid entropy"); - return entropyToMnemonic((0, utils_1.randomBytes)(strength / 8), wordlist2); - } - exports.generateMnemonic = generateMnemonic2; - var calcChecksum = (entropy) => { - const bitsLeft = 8 - entropy.length / 4; - return new Uint8Array([(0, sha256_1.sha256)(entropy)[0] >> bitsLeft << bitsLeft]); - }; - function getCoder(wordlist2) { - if (!Array.isArray(wordlist2) || wordlist2.length !== 2048 || typeof wordlist2[0] !== "string") - throw new Error("Worlist: expected array of 2048 strings"); - wordlist2.forEach((i) => { - if (typeof i !== "string") - throw new Error(`Wordlist: non-string element: ${i}`); - }); - return base_1.utils.chain(base_1.utils.checksum(1, calcChecksum), base_1.utils.radix2(11, true), base_1.utils.alphabet(wordlist2)); - } - function mnemonicToEntropy(mnemonic, wordlist2) { - const { words } = normalize2(mnemonic); - const entropy = getCoder(wordlist2).decode(words); - assertEntropy(entropy); - return entropy; - } - exports.mnemonicToEntropy = mnemonicToEntropy; - function entropyToMnemonic(entropy, wordlist2) { - assertEntropy(entropy); - const words = getCoder(wordlist2).encode(entropy); - return words.join(isJapanese(wordlist2) ? "\u3000" : " "); - } - exports.entropyToMnemonic = entropyToMnemonic; - function validateMnemonic2(mnemonic, wordlist2) { - try { - mnemonicToEntropy(mnemonic, wordlist2); - } catch (e) { - return false; - } - return true; - } - exports.validateMnemonic = validateMnemonic2; - var salt = (passphrase) => nfkd(`mnemonic${passphrase}`); - function mnemonicToSeed(mnemonic, passphrase = "") { - return (0, pbkdf2_1.pbkdf2Async)(sha512_1.sha512, normalize2(mnemonic).nfkd, salt(passphrase), { c: 2048, dkLen: 64 }); - } - exports.mnemonicToSeed = mnemonicToSeed; - function mnemonicToSeedSync2(mnemonic, passphrase = "") { - return (0, pbkdf2_1.pbkdf2)(sha512_1.sha512, normalize2(mnemonic).nfkd, salt(passphrase), { c: 2048, dkLen: 64 }); - } - exports.mnemonicToSeedSync = mnemonicToSeedSync2; - } - }); - - // index.ts - var nostr_tools_exports = {}; - __export(nostr_tools_exports, { - Kind: () => Kind, - SimplePool: () => SimplePool, - finishEvent: () => finishEvent, - fj: () => fakejson_exports, - generatePrivateKey: () => generatePrivateKey, - getBlankEvent: () => getBlankEvent, - getEventHash: () => getEventHash, - getPublicKey: () => getPublicKey, - matchFilter: () => matchFilter, - matchFilters: () => matchFilters, - nip04: () => nip04_exports, - nip05: () => nip05_exports, - nip06: () => nip06_exports, - nip19: () => nip19_exports, - nip26: () => nip26_exports, - nip57: () => nip57_exports, - relayInit: () => relayInit, - serializeEvent: () => serializeEvent, - signEvent: () => signEvent, - utils: () => utils_exports, - validateEvent: () => validateEvent, - verifySignature: () => verifySignature - }); - init_define_process(); - - // keys.ts - init_define_process(); - - // node_modules/@noble/secp256k1/lib/esm/index.js - init_define_process(); - var nodeCrypto = __toESM(require_crypto(), 1); - var _0n = BigInt(0); - var _1n = BigInt(1); - var _2n = BigInt(2); - var _3n = BigInt(3); - var _8n = BigInt(8); - var CURVE = Object.freeze({ - a: _0n, - b: BigInt(7), - P: BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), - n: BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), - h: _1n, - Gx: BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"), - Gy: BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"), - beta: BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee") - }); - var divNearest = (a, b) => (a + b / _2n) / b; - var endo = { - beta: BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"), - splitScalar(k) { - const { n } = CURVE; - const a1 = BigInt("0x3086d221a7d46bcde86c90e49284eb15"); - const b1 = -_1n * BigInt("0xe4437ed6010e88286f547fa90abfe4c3"); - const a2 = BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"); - const b2 = a1; - const POW_2_128 = BigInt("0x100000000000000000000000000000000"); - const c1 = divNearest(b2 * k, n); - const c2 = divNearest(-b1 * k, n); - let k1 = mod(k - c1 * a1 - c2 * a2, n); - let k2 = mod(-c1 * b1 - c2 * b2, n); - const k1neg = k1 > POW_2_128; - const k2neg = k2 > POW_2_128; - if (k1neg) - k1 = n - k1; - if (k2neg) - k2 = n - k2; - if (k1 > POW_2_128 || k2 > POW_2_128) { - throw new Error("splitScalarEndo: Endomorphism failed, k=" + k); - } - return { k1neg, k1, k2neg, k2 }; - } - }; - var fieldLen = 32; - var groupLen = 32; - var compressedLen = fieldLen + 1; - var uncompressedLen = 2 * fieldLen + 1; - function weierstrass(x) { - const { a, b } = CURVE; - const x2 = mod(x * x); - const x3 = mod(x2 * x); - return mod(x3 + a * x + b); - } - var USE_ENDOMORPHISM = CURVE.a === _0n; - var ShaError = class extends Error { - constructor(message) { - super(message); - } - }; - function assertJacPoint(other) { - if (!(other instanceof JacobianPoint)) - throw new TypeError("JacobianPoint expected"); - } - var JacobianPoint = class { - constructor(x, y, z) { - this.x = x; - this.y = y; - this.z = z; - } - static fromAffine(p) { - if (!(p instanceof Point)) { - throw new TypeError("JacobianPoint#fromAffine: expected Point"); - } - if (p.equals(Point.ZERO)) - return JacobianPoint.ZERO; - return new JacobianPoint(p.x, p.y, _1n); - } - static toAffineBatch(points) { - const toInv = invertBatch(points.map((p) => p.z)); - return points.map((p, i) => p.toAffine(toInv[i])); - } - static normalizeZ(points) { - return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine); - } - equals(other) { - assertJacPoint(other); - const { x: X1, y: Y1, z: Z1 } = this; - const { x: X2, y: Y2, z: Z2 } = other; - const Z1Z1 = mod(Z1 * Z1); - const Z2Z2 = mod(Z2 * Z2); - const U1 = mod(X1 * Z2Z2); - const U2 = mod(X2 * Z1Z1); - const S1 = mod(mod(Y1 * Z2) * Z2Z2); - const S2 = mod(mod(Y2 * Z1) * Z1Z1); - return U1 === U2 && S1 === S2; - } - negate() { - return new JacobianPoint(this.x, mod(-this.y), this.z); - } - double() { - const { x: X1, y: Y1, z: Z1 } = this; - const A = mod(X1 * X1); - const B = mod(Y1 * Y1); - const C = mod(B * B); - const x1b = X1 + B; - const D = mod(_2n * (mod(x1b * x1b) - A - C)); - const E = mod(_3n * A); - const F = mod(E * E); - const X3 = mod(F - _2n * D); - const Y3 = mod(E * (D - X3) - _8n * C); - const Z3 = mod(_2n * Y1 * Z1); - return new JacobianPoint(X3, Y3, Z3); - } - add(other) { - assertJacPoint(other); - const { x: X1, y: Y1, z: Z1 } = this; - const { x: X2, y: Y2, z: Z2 } = other; - if (X2 === _0n || Y2 === _0n) - return this; - if (X1 === _0n || Y1 === _0n) - return other; - const Z1Z1 = mod(Z1 * Z1); - const Z2Z2 = mod(Z2 * Z2); - const U1 = mod(X1 * Z2Z2); - const U2 = mod(X2 * Z1Z1); - const S1 = mod(mod(Y1 * Z2) * Z2Z2); - const S2 = mod(mod(Y2 * Z1) * Z1Z1); - const H = mod(U2 - U1); - const r = mod(S2 - S1); - if (H === _0n) { - if (r === _0n) { - return this.double(); - } else { - return JacobianPoint.ZERO; - } - } - const HH = mod(H * H); - const HHH = mod(H * HH); - const V = mod(U1 * HH); - const X3 = mod(r * r - HHH - _2n * V); - const Y3 = mod(r * (V - X3) - S1 * HHH); - const Z3 = mod(Z1 * Z2 * H); - return new JacobianPoint(X3, Y3, Z3); - } - subtract(other) { - return this.add(other.negate()); - } - multiplyUnsafe(scalar) { - const P0 = JacobianPoint.ZERO; - if (typeof scalar === "bigint" && scalar === _0n) - return P0; - let n = normalizeScalar(scalar); - if (n === _1n) - return this; - if (!USE_ENDOMORPHISM) { - let p = P0; - let d2 = this; - while (n > _0n) { - if (n & _1n) - p = p.add(d2); - d2 = d2.double(); - n >>= _1n; - } - return p; - } - let { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); - let k1p = P0; - let k2p = P0; - let d = this; - while (k1 > _0n || k2 > _0n) { - if (k1 & _1n) - k1p = k1p.add(d); - if (k2 & _1n) - k2p = k2p.add(d); - d = d.double(); - k1 >>= _1n; - k2 >>= _1n; - } - if (k1neg) - k1p = k1p.negate(); - if (k2neg) - k2p = k2p.negate(); - k2p = new JacobianPoint(mod(k2p.x * endo.beta), k2p.y, k2p.z); - return k1p.add(k2p); - } - precomputeWindow(W) { - const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1; - const points = []; - let p = this; - let base = p; - for (let window = 0; window < windows; window++) { - base = p; - points.push(base); - for (let i = 1; i < 2 ** (W - 1); i++) { - base = base.add(p); - points.push(base); - } - p = base.double(); - } - return points; - } - wNAF(n, affinePoint) { - if (!affinePoint && this.equals(JacobianPoint.BASE)) - affinePoint = Point.BASE; - const W = affinePoint && affinePoint._WINDOW_SIZE || 1; - if (256 % W) { - throw new Error("Point#wNAF: Invalid precomputation window, must be power of 2"); - } - let precomputes = affinePoint && pointPrecomputes.get(affinePoint); - if (!precomputes) { - precomputes = this.precomputeWindow(W); - if (affinePoint && W !== 1) { - precomputes = JacobianPoint.normalizeZ(precomputes); - pointPrecomputes.set(affinePoint, precomputes); - } - } - let p = JacobianPoint.ZERO; - let f2 = JacobianPoint.BASE; - const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W); - const windowSize = 2 ** (W - 1); - const mask = BigInt(2 ** W - 1); - const maxNumber = 2 ** W; - const shiftBy = BigInt(W); - for (let window = 0; window < windows; window++) { - const offset = window * windowSize; - let wbits = Number(n & mask); - n >>= shiftBy; - if (wbits > windowSize) { - wbits -= maxNumber; - n += _1n; - } - const offset1 = offset; - const offset2 = offset + Math.abs(wbits) - 1; - const cond1 = window % 2 !== 0; - const cond2 = wbits < 0; - if (wbits === 0) { - f2 = f2.add(constTimeNegate(cond1, precomputes[offset1])); - } else { - p = p.add(constTimeNegate(cond2, precomputes[offset2])); - } - } - return { p, f: f2 }; - } - multiply(scalar, affinePoint) { - let n = normalizeScalar(scalar); - let point; - let fake; - if (USE_ENDOMORPHISM) { - const { k1neg, k1, k2neg, k2 } = endo.splitScalar(n); - let { p: k1p, f: f1p } = this.wNAF(k1, affinePoint); - let { p: k2p, f: f2p } = this.wNAF(k2, affinePoint); - k1p = constTimeNegate(k1neg, k1p); - k2p = constTimeNegate(k2neg, k2p); - k2p = new JacobianPoint(mod(k2p.x * endo.beta), k2p.y, k2p.z); - point = k1p.add(k2p); - fake = f1p.add(f2p); - } else { - const { p, f: f2 } = this.wNAF(n, affinePoint); - point = p; - fake = f2; - } - return JacobianPoint.normalizeZ([point, fake])[0]; - } - toAffine(invZ) { - const { x, y, z } = this; - const is0 = this.equals(JacobianPoint.ZERO); - if (invZ == null) - invZ = is0 ? _8n : invert(z); - const iz1 = invZ; - const iz2 = mod(iz1 * iz1); - const iz3 = mod(iz2 * iz1); - const ax = mod(x * iz2); - const ay = mod(y * iz3); - const zz = mod(z * iz1); - if (is0) - return Point.ZERO; - if (zz !== _1n) - throw new Error("invZ was invalid"); - return new Point(ax, ay); - } - }; - JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n); - JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n); - function constTimeNegate(condition, item) { - const neg = item.negate(); - return condition ? neg : item; - } - var pointPrecomputes = /* @__PURE__ */ new WeakMap(); - var Point = class { - constructor(x, y) { - this.x = x; - this.y = y; - } - _setWindowSize(windowSize) { - this._WINDOW_SIZE = windowSize; - pointPrecomputes.delete(this); - } - hasEvenY() { - return this.y % _2n === _0n; - } - static fromCompressedHex(bytes2) { - const isShort = bytes2.length === 32; - const x = bytesToNumber(isShort ? bytes2 : bytes2.subarray(1)); - if (!isValidFieldElement(x)) - throw new Error("Point is not on curve"); - const y2 = weierstrass(x); - let y = sqrtMod(y2); - const isYOdd = (y & _1n) === _1n; - if (isShort) { - if (isYOdd) - y = mod(-y); - } else { - const isFirstByteOdd = (bytes2[0] & 1) === 1; - if (isFirstByteOdd !== isYOdd) - y = mod(-y); - } - const point = new Point(x, y); - point.assertValidity(); - return point; - } - static fromUncompressedHex(bytes2) { - const x = bytesToNumber(bytes2.subarray(1, fieldLen + 1)); - const y = bytesToNumber(bytes2.subarray(fieldLen + 1, fieldLen * 2 + 1)); - const point = new Point(x, y); - point.assertValidity(); - return point; - } - static fromHex(hex2) { - const bytes2 = ensureBytes(hex2); - const len = bytes2.length; - const header = bytes2[0]; - if (len === fieldLen) - return this.fromCompressedHex(bytes2); - if (len === compressedLen && (header === 2 || header === 3)) { - return this.fromCompressedHex(bytes2); - } - if (len === uncompressedLen && header === 4) - return this.fromUncompressedHex(bytes2); - throw new Error(`Point.fromHex: received invalid point. Expected 32-${compressedLen} compressed bytes or ${uncompressedLen} uncompressed bytes, not ${len}`); - } - static fromPrivateKey(privateKey) { - return Point.BASE.multiply(normalizePrivateKey(privateKey)); - } - static fromSignature(msgHash, signature, recovery) { - const { r, s } = normalizeSignature(signature); - if (![0, 1, 2, 3].includes(recovery)) - throw new Error("Cannot recover: invalid recovery bit"); - const h = truncateHash(ensureBytes(msgHash)); - const { n } = CURVE; - const radj = recovery === 2 || recovery === 3 ? r + n : r; - const rinv = invert(radj, n); - const u1 = mod(-h * rinv, n); - const u2 = mod(s * rinv, n); - const prefix = recovery & 1 ? "03" : "02"; - const R = Point.fromHex(prefix + numTo32bStr(radj)); - const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2); - if (!Q) - throw new Error("Cannot recover signature: point at infinify"); - Q.assertValidity(); - return Q; - } - toRawBytes(isCompressed = false) { - return hexToBytes(this.toHex(isCompressed)); - } - toHex(isCompressed = false) { - const x = numTo32bStr(this.x); - if (isCompressed) { - const prefix = this.hasEvenY() ? "02" : "03"; - return `${prefix}${x}`; - } else { - return `04${x}${numTo32bStr(this.y)}`; - } - } - toHexX() { - return this.toHex(true).slice(2); - } - toRawX() { - return this.toRawBytes(true).slice(1); - } - assertValidity() { - const msg = "Point is not on elliptic curve"; - const { x, y } = this; - if (!isValidFieldElement(x) || !isValidFieldElement(y)) - throw new Error(msg); - const left = mod(y * y); - const right = weierstrass(x); - if (mod(left - right) !== _0n) - throw new Error(msg); - } - equals(other) { - return this.x === other.x && this.y === other.y; - } - negate() { - return new Point(this.x, mod(-this.y)); - } - double() { - return JacobianPoint.fromAffine(this).double().toAffine(); - } - add(other) { - return JacobianPoint.fromAffine(this).add(JacobianPoint.fromAffine(other)).toAffine(); - } - subtract(other) { - return this.add(other.negate()); - } - multiply(scalar) { - return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine(); - } - multiplyAndAddUnsafe(Q, a, b) { - const P = JacobianPoint.fromAffine(this); - const aP = a === _0n || a === _1n || this !== Point.BASE ? P.multiplyUnsafe(a) : P.multiply(a); - const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b); - const sum = aP.add(bQ); - return sum.equals(JacobianPoint.ZERO) ? void 0 : sum.toAffine(); - } - }; - Point.BASE = new Point(CURVE.Gx, CURVE.Gy); - Point.ZERO = new Point(_0n, _0n); - function sliceDER(s) { - return Number.parseInt(s[0], 16) >= 8 ? "00" + s : s; - } - function parseDERInt(data) { - if (data.length < 2 || data[0] !== 2) { - throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`); - } - const len = data[1]; - const res = data.subarray(2, len + 2); - if (!len || res.length !== len) { - throw new Error(`Invalid signature integer: wrong length`); - } - if (res[0] === 0 && res[1] <= 127) { - throw new Error("Invalid signature integer: trailing length"); - } - return { data: bytesToNumber(res), left: data.subarray(len + 2) }; - } - function parseDERSignature(data) { - if (data.length < 2 || data[0] != 48) { - throw new Error(`Invalid signature tag: ${bytesToHex(data)}`); - } - if (data[1] !== data.length - 2) { - throw new Error("Invalid signature: incorrect length"); - } - const { data: r, left: sBytes } = parseDERInt(data.subarray(2)); - const { data: s, left: rBytesLeft } = parseDERInt(sBytes); - if (rBytesLeft.length) { - throw new Error(`Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}`); - } - return { r, s }; - } - var Signature = class { - constructor(r, s) { - this.r = r; - this.s = s; - this.assertValidity(); - } - static fromCompact(hex2) { - const arr = hex2 instanceof Uint8Array; - const name = "Signature.fromCompact"; - if (typeof hex2 !== "string" && !arr) - throw new TypeError(`${name}: Expected string or Uint8Array`); - const str = arr ? bytesToHex(hex2) : hex2; - if (str.length !== 128) - throw new Error(`${name}: Expected 64-byte hex`); - return new Signature(hexToNumber(str.slice(0, 64)), hexToNumber(str.slice(64, 128))); - } - static fromDER(hex2) { - const arr = hex2 instanceof Uint8Array; - if (typeof hex2 !== "string" && !arr) - throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`); - const { r, s } = parseDERSignature(arr ? hex2 : hexToBytes(hex2)); - return new Signature(r, s); - } - static fromHex(hex2) { - return this.fromDER(hex2); - } - assertValidity() { - const { r, s } = this; - if (!isWithinCurveOrder(r)) - throw new Error("Invalid Signature: r must be 0 < r < n"); - if (!isWithinCurveOrder(s)) - throw new Error("Invalid Signature: s must be 0 < s < n"); - } - hasHighS() { - const HALF = CURVE.n >> _1n; - return this.s > HALF; - } - normalizeS() { - return this.hasHighS() ? new Signature(this.r, mod(-this.s, CURVE.n)) : this; - } - toDERRawBytes() { - return hexToBytes(this.toDERHex()); - } - toDERHex() { - const sHex = sliceDER(numberToHexUnpadded(this.s)); - const rHex = sliceDER(numberToHexUnpadded(this.r)); - const sHexL = sHex.length / 2; - const rHexL = rHex.length / 2; - const sLen = numberToHexUnpadded(sHexL); - const rLen = numberToHexUnpadded(rHexL); - const length = numberToHexUnpadded(rHexL + sHexL + 4); - return `30${length}02${rLen}${rHex}02${sLen}${sHex}`; - } - toRawBytes() { - return this.toDERRawBytes(); - } - toHex() { - return this.toDERHex(); - } - toCompactRawBytes() { - return hexToBytes(this.toCompactHex()); - } - toCompactHex() { - return numTo32bStr(this.r) + numTo32bStr(this.s); - } - }; - function concatBytes(...arrays) { - if (!arrays.every((b) => b instanceof Uint8Array)) - throw new Error("Uint8Array list expected"); - if (arrays.length === 1) - return arrays[0]; - const length = arrays.reduce((a, arr) => a + arr.length, 0); - const result = new Uint8Array(length); - for (let i = 0, pad = 0; i < arrays.length; i++) { - const arr = arrays[i]; - result.set(arr, pad); - pad += arr.length; - } - return result; - } - var hexes = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, "0")); - function bytesToHex(uint8a) { - if (!(uint8a instanceof Uint8Array)) - throw new Error("Expected Uint8Array"); - let hex2 = ""; - for (let i = 0; i < uint8a.length; i++) { - hex2 += hexes[uint8a[i]]; - } - return hex2; - } - var POW_2_256 = BigInt("0x10000000000000000000000000000000000000000000000000000000000000000"); - function numTo32bStr(num) { - if (typeof num !== "bigint") - throw new Error("Expected bigint"); - if (!(_0n <= num && num < POW_2_256)) - throw new Error("Expected number 0 <= n < 2^256"); - return num.toString(16).padStart(64, "0"); - } - function numTo32b(num) { - const b = hexToBytes(numTo32bStr(num)); - if (b.length !== 32) - throw new Error("Error: expected 32 bytes"); - return b; - } - function numberToHexUnpadded(num) { - const hex2 = num.toString(16); - return hex2.length & 1 ? `0${hex2}` : hex2; - } - function hexToNumber(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToNumber: expected string, got " + typeof hex2); - } - return BigInt(`0x${hex2}`); - } - function hexToBytes(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToBytes: expected string, got " + typeof hex2); - } - if (hex2.length % 2) - throw new Error("hexToBytes: received invalid unpadded hex" + hex2.length); - const array = new Uint8Array(hex2.length / 2); - for (let i = 0; i < array.length; i++) { - const j = i * 2; - const hexByte = hex2.slice(j, j + 2); - const byte = Number.parseInt(hexByte, 16); - if (Number.isNaN(byte) || byte < 0) - throw new Error("Invalid byte sequence"); - array[i] = byte; - } - return array; - } - function bytesToNumber(bytes2) { - return hexToNumber(bytesToHex(bytes2)); - } - function ensureBytes(hex2) { - return hex2 instanceof Uint8Array ? Uint8Array.from(hex2) : hexToBytes(hex2); - } - function normalizeScalar(num) { - if (typeof num === "number" && Number.isSafeInteger(num) && num > 0) - return BigInt(num); - if (typeof num === "bigint" && isWithinCurveOrder(num)) - return num; - throw new TypeError("Expected valid private scalar: 0 < scalar < curve.n"); - } - function mod(a, b = CURVE.P) { - const result = a % b; - return result >= _0n ? result : b + result; - } - function pow2(x, power) { - const { P } = CURVE; - let res = x; - while (power-- > _0n) { - res *= res; - res %= P; - } - return res; - } - function sqrtMod(x) { - const { P } = CURVE; - const _6n = BigInt(6); - const _11n = BigInt(11); - const _22n = BigInt(22); - const _23n = BigInt(23); - const _44n = BigInt(44); - const _88n = BigInt(88); - const b2 = x * x * x % P; - const b3 = b2 * b2 * x % P; - const b6 = pow2(b3, _3n) * b3 % P; - const b9 = pow2(b6, _3n) * b3 % P; - const b11 = pow2(b9, _2n) * b2 % P; - const b22 = pow2(b11, _11n) * b11 % P; - const b44 = pow2(b22, _22n) * b22 % P; - const b88 = pow2(b44, _44n) * b44 % P; - const b176 = pow2(b88, _88n) * b88 % P; - const b220 = pow2(b176, _44n) * b44 % P; - const b223 = pow2(b220, _3n) * b3 % P; - const t1 = pow2(b223, _23n) * b22 % P; - const t2 = pow2(t1, _6n) * b2 % P; - const rt = pow2(t2, _2n); - const xc = rt * rt % P; - if (xc !== x) - throw new Error("Cannot find square root"); - return rt; - } - function invert(number2, modulo = CURVE.P) { - if (number2 === _0n || modulo <= _0n) { - throw new Error(`invert: expected positive integers, got n=${number2} mod=${modulo}`); - } - let a = mod(number2, modulo); - let b = modulo; - let x = _0n, y = _1n, u = _1n, v = _0n; - while (a !== _0n) { - const q = b / a; - const r = b % a; - const m = x - u * q; - const n = y - v * q; - b = a, a = r, x = u, y = v, u = m, v = n; - } - const gcd2 = b; - if (gcd2 !== _1n) - throw new Error("invert: does not exist"); - return mod(x, modulo); - } - function invertBatch(nums, p = CURVE.P) { - const scratch = new Array(nums.length); - const lastMultiplied = nums.reduce((acc, num, i) => { - if (num === _0n) - return acc; - scratch[i] = acc; - return mod(acc * num, p); - }, _1n); - const inverted = invert(lastMultiplied, p); - nums.reduceRight((acc, num, i) => { - if (num === _0n) - return acc; - scratch[i] = mod(acc * scratch[i], p); - return mod(acc * num, p); - }, inverted); - return scratch; - } - function bits2int_2(bytes2) { - const delta = bytes2.length * 8 - groupLen * 8; - const num = bytesToNumber(bytes2); - return delta > 0 ? num >> BigInt(delta) : num; - } - function truncateHash(hash2, truncateOnly = false) { - const h = bits2int_2(hash2); - if (truncateOnly) - return h; - const { n } = CURVE; - return h >= n ? h - n : h; - } - var _sha256Sync; - var _hmacSha256Sync; - function isWithinCurveOrder(num) { - return _0n < num && num < CURVE.n; - } - function isValidFieldElement(num) { - return _0n < num && num < CURVE.P; - } - function normalizePrivateKey(key) { - let num; - if (typeof key === "bigint") { - num = key; - } else if (typeof key === "number" && Number.isSafeInteger(key) && key > 0) { - num = BigInt(key); - } else if (typeof key === "string") { - if (key.length !== 2 * groupLen) - throw new Error("Expected 32 bytes of private key"); - num = hexToNumber(key); - } else if (key instanceof Uint8Array) { - if (key.length !== groupLen) - throw new Error("Expected 32 bytes of private key"); - num = bytesToNumber(key); - } else { - throw new TypeError("Expected valid private key"); - } - if (!isWithinCurveOrder(num)) - throw new Error("Expected private key: 0 < key < n"); - return num; - } - function normalizePublicKey(publicKey) { - if (publicKey instanceof Point) { - publicKey.assertValidity(); - return publicKey; - } else { - return Point.fromHex(publicKey); - } - } - function normalizeSignature(signature) { - if (signature instanceof Signature) { - signature.assertValidity(); - return signature; - } - try { - return Signature.fromDER(signature); - } catch (error) { - return Signature.fromCompact(signature); - } - } - function isProbPub(item) { - const arr = item instanceof Uint8Array; - const str = typeof item === "string"; - const len = (arr || str) && item.length; - if (arr) - return len === compressedLen || len === uncompressedLen; - if (str) - return len === compressedLen * 2 || len === uncompressedLen * 2; - if (item instanceof Point) - return true; - return false; - } - function getSharedSecret(privateA, publicB, isCompressed = false) { - if (isProbPub(privateA)) - throw new TypeError("getSharedSecret: first arg must be private key"); - if (!isProbPub(publicB)) - throw new TypeError("getSharedSecret: second arg must be public key"); - const b = normalizePublicKey(publicB); - b.assertValidity(); - return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed); - } - function schnorrChallengeFinalize(ch) { - return mod(bytesToNumber(ch), CURVE.n); - } - var SchnorrSignature = class { - constructor(r, s) { - this.r = r; - this.s = s; - this.assertValidity(); - } - static fromHex(hex2) { - const bytes2 = ensureBytes(hex2); - if (bytes2.length !== 64) - throw new TypeError(`SchnorrSignature.fromHex: expected 64 bytes, not ${bytes2.length}`); - const r = bytesToNumber(bytes2.subarray(0, 32)); - const s = bytesToNumber(bytes2.subarray(32, 64)); - return new SchnorrSignature(r, s); - } - assertValidity() { - const { r, s } = this; - if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) - throw new Error("Invalid signature"); - } - toHex() { - return numTo32bStr(this.r) + numTo32bStr(this.s); - } - toRawBytes() { - return hexToBytes(this.toHex()); - } - }; - function schnorrGetPublicKey(privateKey) { - return Point.fromPrivateKey(privateKey).toRawX(); - } - var InternalSchnorrSignature = class { - constructor(message, privateKey, auxRand = utils.randomBytes()) { - if (message == null) - throw new TypeError(`sign: Expected valid message, not "${message}"`); - this.m = ensureBytes(message); - const { x, scalar } = this.getScalar(normalizePrivateKey(privateKey)); - this.px = x; - this.d = scalar; - this.rand = ensureBytes(auxRand); - if (this.rand.length !== 32) - throw new TypeError("sign: Expected 32 bytes of aux randomness"); - } - getScalar(priv) { - const point = Point.fromPrivateKey(priv); - const scalar = point.hasEvenY() ? priv : CURVE.n - priv; - return { point, scalar, x: point.toRawX() }; - } - initNonce(d, t0h) { - return numTo32b(d ^ bytesToNumber(t0h)); - } - finalizeNonce(k0h) { - const k0 = mod(bytesToNumber(k0h), CURVE.n); - if (k0 === _0n) - throw new Error("sign: Creation of signature failed. k is zero"); - const { point: R, x: rx, scalar: k } = this.getScalar(k0); - return { R, rx, k }; - } - finalizeSig(R, k, e, d) { - return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes(); - } - error() { - throw new Error("sign: Invalid signature produced"); - } - async calc() { - const { m, d, px, rand } = this; - const tag = utils.taggedHash; - const t = this.initNonce(d, await tag(TAGS.aux, rand)); - const { R, rx, k } = this.finalizeNonce(await tag(TAGS.nonce, t, px, m)); - const e = schnorrChallengeFinalize(await tag(TAGS.challenge, rx, px, m)); - const sig = this.finalizeSig(R, k, e, d); - if (!await schnorrVerify(sig, m, px)) - this.error(); - return sig; - } - calcSync() { - const { m, d, px, rand } = this; - const tag = utils.taggedHashSync; - const t = this.initNonce(d, tag(TAGS.aux, rand)); - const { R, rx, k } = this.finalizeNonce(tag(TAGS.nonce, t, px, m)); - const e = schnorrChallengeFinalize(tag(TAGS.challenge, rx, px, m)); - const sig = this.finalizeSig(R, k, e, d); - if (!schnorrVerifySync(sig, m, px)) - this.error(); - return sig; - } - }; - async function schnorrSign(msg, privKey, auxRand) { - return new InternalSchnorrSignature(msg, privKey, auxRand).calc(); - } - function schnorrSignSync(msg, privKey, auxRand) { - return new InternalSchnorrSignature(msg, privKey, auxRand).calcSync(); - } - function initSchnorrVerify(signature, message, publicKey) { - const raw = signature instanceof SchnorrSignature; - const sig = raw ? signature : SchnorrSignature.fromHex(signature); - if (raw) - sig.assertValidity(); - return { - ...sig, - m: ensureBytes(message), - P: normalizePublicKey(publicKey) - }; - } - function finalizeSchnorrVerify(r, P, s, e) { - const R = Point.BASE.multiplyAndAddUnsafe(P, normalizePrivateKey(s), mod(-e, CURVE.n)); - if (!R || !R.hasEvenY() || R.x !== r) - return false; - return true; - } - async function schnorrVerify(signature, message, publicKey) { - try { - const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); - const e = schnorrChallengeFinalize(await utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m)); - return finalizeSchnorrVerify(r, P, s, e); - } catch (error) { - return false; - } - } - function schnorrVerifySync(signature, message, publicKey) { - try { - const { r, s, m, P } = initSchnorrVerify(signature, message, publicKey); - const e = schnorrChallengeFinalize(utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m)); - return finalizeSchnorrVerify(r, P, s, e); - } catch (error) { - if (error instanceof ShaError) - throw error; - return false; - } - } - var schnorr = { - Signature: SchnorrSignature, - getPublicKey: schnorrGetPublicKey, - sign: schnorrSign, - verify: schnorrVerify, - signSync: schnorrSignSync, - verifySync: schnorrVerifySync - }; - Point.BASE._setWindowSize(8); - var crypto2 = { - node: nodeCrypto, - web: typeof self === "object" && "crypto" in self ? self.crypto : void 0 - }; - var TAGS = { - challenge: "BIP0340/challenge", - aux: "BIP0340/aux", - nonce: "BIP0340/nonce" - }; - var TAGGED_HASH_PREFIXES = {}; - var utils = { - bytesToHex, - hexToBytes, - concatBytes, - mod, - invert, - isValidPrivateKey(privateKey) { - try { - normalizePrivateKey(privateKey); - return true; - } catch (error) { - return false; - } - }, - _bigintTo32Bytes: numTo32b, - _normalizePrivateKey: normalizePrivateKey, - hashToPrivateKey: (hash2) => { - hash2 = ensureBytes(hash2); - const minLen = groupLen + 8; - if (hash2.length < minLen || hash2.length > 1024) { - throw new Error(`Expected valid bytes of private key as per FIPS 186`); - } - const num = mod(bytesToNumber(hash2), CURVE.n - _1n) + _1n; - return numTo32b(num); - }, - randomBytes: (bytesLength = 32) => { - if (crypto2.web) { - return crypto2.web.getRandomValues(new Uint8Array(bytesLength)); - } else if (crypto2.node) { - const { randomBytes: randomBytes2 } = crypto2.node; - return Uint8Array.from(randomBytes2(bytesLength)); - } else { - throw new Error("The environment doesn't have randomBytes function"); - } - }, - randomPrivateKey: () => utils.hashToPrivateKey(utils.randomBytes(groupLen + 8)), - precompute(windowSize = 8, point = Point.BASE) { - const cached = point === Point.BASE ? point : new Point(point.x, point.y); - cached._setWindowSize(windowSize); - cached.multiply(_3n); - return cached; - }, - sha256: async (...messages) => { - if (crypto2.web) { - const buffer = await crypto2.web.subtle.digest("SHA-256", concatBytes(...messages)); - return new Uint8Array(buffer); - } else if (crypto2.node) { - const { createHash } = crypto2.node; - const hash2 = createHash("sha256"); - messages.forEach((m) => hash2.update(m)); - return Uint8Array.from(hash2.digest()); - } else { - throw new Error("The environment doesn't have sha256 function"); - } - }, - hmacSha256: async (key, ...messages) => { - if (crypto2.web) { - const ckey = await crypto2.web.subtle.importKey("raw", key, { name: "HMAC", hash: { name: "SHA-256" } }, false, ["sign"]); - const message = concatBytes(...messages); - const buffer = await crypto2.web.subtle.sign("HMAC", ckey, message); - return new Uint8Array(buffer); - } else if (crypto2.node) { - const { createHmac } = crypto2.node; - const hash2 = createHmac("sha256", key); - messages.forEach((m) => hash2.update(m)); - return Uint8Array.from(hash2.digest()); - } else { - throw new Error("The environment doesn't have hmac-sha256 function"); - } - }, - sha256Sync: void 0, - hmacSha256Sync: void 0, - taggedHash: async (tag, ...messages) => { - let tagP = TAGGED_HASH_PREFIXES[tag]; - if (tagP === void 0) { - const tagH = await utils.sha256(Uint8Array.from(tag, (c) => c.charCodeAt(0))); - tagP = concatBytes(tagH, tagH); - TAGGED_HASH_PREFIXES[tag] = tagP; - } - return utils.sha256(tagP, ...messages); - }, - taggedHashSync: (tag, ...messages) => { - if (typeof _sha256Sync !== "function") - throw new ShaError("sha256Sync is undefined, you need to set it"); - let tagP = TAGGED_HASH_PREFIXES[tag]; - if (tagP === void 0) { - const tagH = _sha256Sync(Uint8Array.from(tag, (c) => c.charCodeAt(0))); - tagP = concatBytes(tagH, tagH); - TAGGED_HASH_PREFIXES[tag] = tagP; - } - return _sha256Sync(tagP, ...messages); - }, - _JacobianPoint: JacobianPoint - }; - Object.defineProperties(utils, { - sha256Sync: { - configurable: false, - get() { - return _sha256Sync; - }, - set(val) { - if (!_sha256Sync) - _sha256Sync = val; - } - }, - hmacSha256Sync: { - configurable: false, - get() { - return _hmacSha256Sync; - }, - set(val) { - if (!_hmacSha256Sync) - _hmacSha256Sync = val; - } - } - }); - - // keys.ts - function generatePrivateKey() { - return utils.bytesToHex(utils.randomPrivateKey()); - } - function getPublicKey(privateKey) { - return utils.bytesToHex(schnorr.getPublicKey(privateKey)); - } - - // relay.ts - init_define_process(); - - // event.ts - init_define_process(); - - // node_modules/@noble/hashes/esm/sha256.js - init_define_process(); - - // node_modules/@noble/hashes/esm/_sha2.js - init_define_process(); - - // node_modules/@noble/hashes/esm/utils.js - init_define_process(); - - // node_modules/@noble/hashes/esm/cryptoBrowser.js - init_define_process(); - var crypto3 = { - node: void 0, - web: typeof self === "object" && "crypto" in self ? self.crypto : void 0 - }; - - // node_modules/@noble/hashes/esm/utils.js - var createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); - var rotr = (word, shift) => word << 32 - shift | word >>> shift; - var isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; - if (!isLE) - throw new Error("Non little-endian hardware is not supported"); - var hexes2 = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, "0")); - var nextTick = (() => { - const nodeRequire = typeof module !== "undefined" && typeof module.require === "function" && module.require.bind(module); - try { - if (nodeRequire) { - const { setImmediate } = nodeRequire("timers"); - return () => new Promise((resolve) => setImmediate(resolve)); - } - } catch (e) { - } - return () => new Promise((resolve) => setTimeout(resolve, 0)); - })(); - function utf8ToBytes(str) { - if (typeof str !== "string") { - throw new TypeError(`utf8ToBytes expected string, got ${typeof str}`); - } - return new TextEncoder().encode(str); - } - function toBytes(data) { - if (typeof data === "string") - data = utf8ToBytes(data); - if (!(data instanceof Uint8Array)) - throw new TypeError(`Expected input type is Uint8Array (got ${typeof data})`); - return data; - } - function assertNumber(n) { - if (!Number.isSafeInteger(n) || n < 0) - throw new Error(`Wrong positive integer: ${n}`); - } - function assertHash(hash2) { - if (typeof hash2 !== "function" || typeof hash2.create !== "function") - throw new Error("Hash should be wrapped by utils.wrapConstructor"); - assertNumber(hash2.outputLen); - assertNumber(hash2.blockLen); - } - var Hash = class { - clone() { - return this._cloneInto(); - } - }; - function wrapConstructor(hashConstructor) { - const hashC = (message) => hashConstructor().update(toBytes(message)).digest(); - const tmp = hashConstructor(); - hashC.outputLen = tmp.outputLen; - hashC.blockLen = tmp.blockLen; - hashC.create = () => hashConstructor(); - return hashC; - } - function randomBytes(bytesLength = 32) { - if (crypto3.web) { - return crypto3.web.getRandomValues(new Uint8Array(bytesLength)); - } else if (crypto3.node) { - return new Uint8Array(crypto3.node.randomBytes(bytesLength).buffer); - } else { - throw new Error("The environment doesn't have randomBytes function"); - } - } - - // node_modules/@noble/hashes/esm/_sha2.js - function setBigUint64(view, byteOffset, value, isLE3) { - if (typeof view.setBigUint64 === "function") - return view.setBigUint64(byteOffset, value, isLE3); - const _32n2 = BigInt(32); - const _u32_max = BigInt(4294967295); - const wh = Number(value >> _32n2 & _u32_max); - const wl = Number(value & _u32_max); - const h = isLE3 ? 4 : 0; - const l = isLE3 ? 0 : 4; - view.setUint32(byteOffset + h, wh, isLE3); - view.setUint32(byteOffset + l, wl, isLE3); - } - var SHA2 = class extends Hash { - constructor(blockLen, outputLen, padOffset, isLE3) { - super(); - this.blockLen = blockLen; - this.outputLen = outputLen; - this.padOffset = padOffset; - this.isLE = isLE3; - this.finished = false; - this.length = 0; - this.pos = 0; - this.destroyed = false; - this.buffer = new Uint8Array(blockLen); - this.view = createView(this.buffer); - } - update(data) { - if (this.destroyed) - throw new Error("instance is destroyed"); - const { view, buffer, blockLen, finished } = this; - if (finished) - throw new Error("digest() was already called"); - data = toBytes(data); - const len = data.length; - for (let pos = 0; pos < len; ) { - const take = Math.min(blockLen - this.pos, len - pos); - if (take === blockLen) { - const dataView = createView(data); - for (; blockLen <= len - pos; pos += blockLen) - this.process(dataView, pos); - continue; - } - buffer.set(data.subarray(pos, pos + take), this.pos); - this.pos += take; - pos += take; - if (this.pos === blockLen) { - this.process(view, 0); - this.pos = 0; - } - } - this.length += data.length; - this.roundClean(); - return this; - } - digestInto(out) { - if (this.destroyed) - throw new Error("instance is destroyed"); - if (!(out instanceof Uint8Array) || out.length < this.outputLen) - throw new Error("_Sha2: Invalid output buffer"); - if (this.finished) - throw new Error("digest() was already called"); - this.finished = true; - const { buffer, view, blockLen, isLE: isLE3 } = this; - let { pos } = this; - buffer[pos++] = 128; - this.buffer.subarray(pos).fill(0); - if (this.padOffset > blockLen - pos) { - this.process(view, 0); - pos = 0; - } - for (let i = pos; i < blockLen; i++) - buffer[i] = 0; - setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE3); - this.process(view, 0); - const oview = createView(out); - this.get().forEach((v, i) => oview.setUint32(4 * i, v, isLE3)); - } - digest() { - const { buffer, outputLen } = this; - this.digestInto(buffer); - const res = buffer.slice(0, outputLen); - this.destroy(); - return res; - } - _cloneInto(to) { - to || (to = new this.constructor()); - to.set(...this.get()); - const { blockLen, buffer, length, finished, destroyed, pos } = this; - to.length = length; - to.pos = pos; - to.finished = finished; - to.destroyed = destroyed; - if (length % blockLen) - to.buffer.set(buffer); - return to; - } - }; - - // node_modules/@noble/hashes/esm/sha256.js - var Chi = (a, b, c) => a & b ^ ~a & c; - var Maj = (a, b, c) => a & b ^ a & c ^ b & c; - var SHA256_K = new Uint32Array([ - 1116352408, - 1899447441, - 3049323471, - 3921009573, - 961987163, - 1508970993, - 2453635748, - 2870763221, - 3624381080, - 310598401, - 607225278, - 1426881987, - 1925078388, - 2162078206, - 2614888103, - 3248222580, - 3835390401, - 4022224774, - 264347078, - 604807628, - 770255983, - 1249150122, - 1555081692, - 1996064986, - 2554220882, - 2821834349, - 2952996808, - 3210313671, - 3336571891, - 3584528711, - 113926993, - 338241895, - 666307205, - 773529912, - 1294757372, - 1396182291, - 1695183700, - 1986661051, - 2177026350, - 2456956037, - 2730485921, - 2820302411, - 3259730800, - 3345764771, - 3516065817, - 3600352804, - 4094571909, - 275423344, - 430227734, - 506948616, - 659060556, - 883997877, - 958139571, - 1322822218, - 1537002063, - 1747873779, - 1955562222, - 2024104815, - 2227730452, - 2361852424, - 2428436474, - 2756734187, - 3204031479, - 3329325298 - ]); - var IV = new Uint32Array([ - 1779033703, - 3144134277, - 1013904242, - 2773480762, - 1359893119, - 2600822924, - 528734635, - 1541459225 - ]); - var SHA256_W = new Uint32Array(64); - var SHA256 = class extends SHA2 { - constructor() { - super(64, 32, 8, false); - this.A = IV[0] | 0; - this.B = IV[1] | 0; - this.C = IV[2] | 0; - this.D = IV[3] | 0; - this.E = IV[4] | 0; - this.F = IV[5] | 0; - this.G = IV[6] | 0; - this.H = IV[7] | 0; - } - get() { - const { A, B, C, D, E, F, G, H } = this; - return [A, B, C, D, E, F, G, H]; - } - set(A, B, C, D, E, F, G, H) { - this.A = A | 0; - this.B = B | 0; - this.C = C | 0; - this.D = D | 0; - this.E = E | 0; - this.F = F | 0; - this.G = G | 0; - this.H = H | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) - SHA256_W[i] = view.getUint32(offset, false); - for (let i = 16; i < 64; i++) { - const W15 = SHA256_W[i - 15]; - const W2 = SHA256_W[i - 2]; - const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3; - const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10; - SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0; - } - let { A, B, C, D, E, F, G, H } = this; - for (let i = 0; i < 64; i++) { - const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25); - const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0; - const sigma0 = rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22); - const T2 = sigma0 + Maj(A, B, C) | 0; - H = G; - G = F; - F = E; - E = D + T1 | 0; - D = C; - C = B; - B = A; - A = T1 + T2 | 0; - } - A = A + this.A | 0; - B = B + this.B | 0; - C = C + this.C | 0; - D = D + this.D | 0; - E = E + this.E | 0; - F = F + this.F | 0; - G = G + this.G | 0; - H = H + this.H | 0; - this.set(A, B, C, D, E, F, G, H); - } - roundClean() { - SHA256_W.fill(0); - } - destroy() { - this.set(0, 0, 0, 0, 0, 0, 0, 0); - this.buffer.fill(0); - } - }; - var sha256 = wrapConstructor(() => new SHA256()); - - // utils.ts - var utils_exports = {}; - __export(utils_exports, { - insertEventIntoAscendingList: () => insertEventIntoAscendingList, - insertEventIntoDescendingList: () => insertEventIntoDescendingList, - normalizeURL: () => normalizeURL, - utf8Decoder: () => utf8Decoder, - utf8Encoder: () => utf8Encoder - }); - init_define_process(); - var utf8Decoder = new TextDecoder("utf-8"); - var utf8Encoder = new TextEncoder(); - function normalizeURL(url) { - let p = new URL(url); - p.pathname = p.pathname.replace(/\/+/g, "/"); - if (p.pathname.endsWith("/")) - p.pathname = p.pathname.slice(0, -1); - if (p.port === "80" && p.protocol === "ws:" || p.port === "443" && p.protocol === "wss:") - p.port = ""; - p.searchParams.sort(); - p.hash = ""; - return p.toString(); - } - function insertEventIntoDescendingList(sortedArray, event) { - let start = 0; - let end = sortedArray.length - 1; - let midPoint; - let position = start; - if (end < 0) { - position = 0; - } else if (event.created_at < sortedArray[end].created_at) { - position = end + 1; - } else if (event.created_at >= sortedArray[start].created_at) { - position = start; - } else - while (true) { - if (end <= start + 1) { - position = end; - break; - } - midPoint = Math.floor(start + (end - start) / 2); - if (sortedArray[midPoint].created_at > event.created_at) { - start = midPoint; - } else if (sortedArray[midPoint].created_at < event.created_at) { - end = midPoint; - } else { - position = midPoint; - break; - } - } - if (sortedArray[position]?.id !== event.id) { - return [ - ...sortedArray.slice(0, position), - event, - ...sortedArray.slice(position) - ]; - } - return sortedArray; - } - function insertEventIntoAscendingList(sortedArray, event) { - let start = 0; - let end = sortedArray.length - 1; - let midPoint; - let position = start; - if (end < 0) { - position = 0; - } else if (event.created_at > sortedArray[end].created_at) { - position = end + 1; - } else if (event.created_at <= sortedArray[start].created_at) { - position = start; - } else - while (true) { - if (end <= start + 1) { - position = end; - break; - } - midPoint = Math.floor(start + (end - start) / 2); - if (sortedArray[midPoint].created_at < event.created_at) { - start = midPoint; - } else if (sortedArray[midPoint].created_at > event.created_at) { - end = midPoint; - } else { - position = midPoint; - break; - } - } - if (sortedArray[position]?.id !== event.id) { - return [ - ...sortedArray.slice(0, position), - event, - ...sortedArray.slice(position) - ]; - } - return sortedArray; - } - - // event.ts - var Kind = /* @__PURE__ */ ((Kind2) => { - Kind2[Kind2["Metadata"] = 0] = "Metadata"; - Kind2[Kind2["Text"] = 1] = "Text"; - Kind2[Kind2["RecommendRelay"] = 2] = "RecommendRelay"; - Kind2[Kind2["Contacts"] = 3] = "Contacts"; - Kind2[Kind2["EncryptedDirectMessage"] = 4] = "EncryptedDirectMessage"; - Kind2[Kind2["EventDeletion"] = 5] = "EventDeletion"; - Kind2[Kind2["Reaction"] = 7] = "Reaction"; - Kind2[Kind2["ChannelCreation"] = 40] = "ChannelCreation"; - Kind2[Kind2["ChannelMetadata"] = 41] = "ChannelMetadata"; - Kind2[Kind2["ChannelMessage"] = 42] = "ChannelMessage"; - Kind2[Kind2["ChannelHideMessage"] = 43] = "ChannelHideMessage"; - Kind2[Kind2["ChannelMuteUser"] = 44] = "ChannelMuteUser"; - Kind2[Kind2["Report"] = 1984] = "Report"; - Kind2[Kind2["ZapRequest"] = 9734] = "ZapRequest"; - Kind2[Kind2["Zap"] = 9735] = "Zap"; - Kind2[Kind2["RelayList"] = 10002] = "RelayList"; - Kind2[Kind2["ClientAuth"] = 22242] = "ClientAuth"; - Kind2[Kind2["Article"] = 30023] = "Article"; - return Kind2; - })(Kind || {}); - function getBlankEvent() { - return { - kind: 255, - content: "", - tags: [], - created_at: 0 - }; - } - function finishEvent(t, privateKey) { - let event = t; - event.pubkey = getPublicKey(privateKey); - event.id = getEventHash(event); - event.sig = signEvent(event, privateKey); - return event; - } - function serializeEvent(evt) { - if (!validateEvent(evt)) - throw new Error("can't serialize event with wrong or missing properties"); - return JSON.stringify([ - 0, - evt.pubkey, - evt.created_at, - evt.kind, - evt.tags, - evt.content - ]); - } - function getEventHash(event) { - let eventHash = sha256(utf8Encoder.encode(serializeEvent(event))); - return utils.bytesToHex(eventHash); - } - function validateEvent(event) { - if (typeof event !== "object") - return false; - if (typeof event.content !== "string") - return false; - if (typeof event.created_at !== "number") - return false; - if (typeof event.pubkey !== "string") - return false; - if (!event.pubkey.match(/^[a-f0-9]{64}$/)) - return false; - if (!Array.isArray(event.tags)) - return false; - for (let i = 0; i < event.tags.length; i++) { - let tag = event.tags[i]; - if (!Array.isArray(tag)) - return false; - for (let j = 0; j < tag.length; j++) { - if (typeof tag[j] === "object") - return false; - } - } - return true; - } - function verifySignature(event) { - return schnorr.verifySync( - event.sig, - getEventHash(event), - event.pubkey - ); - } - function signEvent(event, key) { - return utils.bytesToHex( - schnorr.signSync(getEventHash(event), key) - ); - } - - // filter.ts - init_define_process(); - function matchFilter(filter, event) { - if (filter.ids && filter.ids.indexOf(event.id) === -1) - return false; - if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) - return false; - if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) - return false; - for (let f2 in filter) { - if (f2[0] === "#") { - let tagName = f2.slice(1); - let values = filter[`#${tagName}`]; - if (values && !event.tags.find( - ([t, v]) => t === f2.slice(1) && values.indexOf(v) !== -1 - )) - return false; - } - } - if (filter.since && event.created_at < filter.since) - return false; - if (filter.until && event.created_at >= filter.until) - return false; - return true; - } - function matchFilters(filters, event) { - for (let i = 0; i < filters.length; i++) { - if (matchFilter(filters[i], event)) - return true; - } - return false; - } - - // fakejson.ts - var fakejson_exports = {}; - __export(fakejson_exports, { - getHex64: () => getHex64, - getInt: () => getInt, - getSubscriptionId: () => getSubscriptionId, - matchEventId: () => matchEventId, - matchEventKind: () => matchEventKind, - matchEventPubkey: () => matchEventPubkey - }); - init_define_process(); - function getHex64(json, field) { - let len = field.length + 3; - let idx = json.indexOf(`"${field}":`) + len; - let s = json.slice(idx).indexOf(`"`) + idx + 1; - return json.slice(s, s + 64); - } - function getInt(json, field) { - let len = field.length; - let idx = json.indexOf(`"${field}":`) + len + 3; - let sliced = json.slice(idx); - let end = Math.min(sliced.indexOf(","), sliced.indexOf("}")); - return parseInt(sliced.slice(0, end), 10); - } - function getSubscriptionId(json) { - let idx = json.slice(0, 22).indexOf(`"EVENT"`); - if (idx === -1) - return null; - let pstart = json.slice(idx + 7 + 1).indexOf(`"`); - if (pstart === -1) - return null; - let start = idx + 7 + 1 + pstart; - let pend = json.slice(start + 1, 80).indexOf(`"`); - if (pend === -1) - return null; - let end = start + 1 + pend; - return json.slice(start + 1, end); - } - function matchEventId(json, id) { - return id === getHex64(json, "id"); - } - function matchEventPubkey(json, pubkey) { - return pubkey === getHex64(json, "pubkey"); - } - function matchEventKind(json, kind) { - return kind === getInt(json, "kind"); - } - - // relay.ts - function relayInit(url) { - var ws; - var openSubs = {}; - var listeners = { - connect: [], - disconnect: [], - error: [], - notice: [] - }; - var subListeners = {}; - var pubListeners = {}; - async function connectRelay() { - return new Promise((resolve, reject) => { - ws = new WebSocket(url); - ws.onopen = () => { - listeners.connect.forEach((cb) => cb()); - resolve(); - }; - ws.onerror = () => { - listeners.error.forEach((cb) => cb()); - reject(); - }; - ws.onclose = async () => { - listeners.disconnect.forEach((cb) => cb()); - }; - let incomingMessageQueue = []; - let handleNextInterval; - ws.onmessage = (e) => { - incomingMessageQueue.push(e.data); - if (!handleNextInterval) { - handleNextInterval = setInterval(handleNext, 0); - } - }; - function handleNext() { - if (incomingMessageQueue.length === 0) { - clearInterval(handleNextInterval); - handleNextInterval = null; - return; - } - var json = incomingMessageQueue.shift(); - if (!json) - return; - let subid = getSubscriptionId(json); - if (subid) { - let so = openSubs[subid]; - if (so && so.alreadyHaveEvent && so.alreadyHaveEvent(getHex64(json, "id"), url)) { - return; - } - } - try { - let data = JSON.parse(json); - switch (data[0]) { - case "EVENT": - let id = data[1]; - let event = data[2]; - if (validateEvent(event) && openSubs[id] && (openSubs[id].skipVerification || verifySignature(event)) && matchFilters(openSubs[id].filters, event)) { - openSubs[id]; - (subListeners[id]?.event || []).forEach((cb) => cb(event)); - } - return; - case "EOSE": { - let id2 = data[1]; - if (id2 in subListeners) { - subListeners[id2].eose.forEach((cb) => cb()); - subListeners[id2].eose = []; - } - return; - } - case "OK": { - let id2 = data[1]; - let ok = data[2]; - let reason = data[3] || ""; - if (id2 in pubListeners) { - if (ok) - pubListeners[id2].ok.forEach((cb) => cb()); - else - pubListeners[id2].failed.forEach((cb) => cb(reason)); - pubListeners[id2].ok = []; - pubListeners[id2].failed = []; - } - return; - } - case "NOTICE": - let notice = data[1]; - listeners.notice.forEach((cb) => cb(notice)); - return; - } - } catch (err) { - return; - } - } - }); - } - function connected() { - return ws?.readyState === 1; - } - async function connect() { - if (connected()) - return; - await connectRelay(); - } - async function trySend(params) { - let msg = JSON.stringify(params); - if (!connected()) { - await new Promise((resolve) => setTimeout(resolve, 1e3)); - if (!connected()) { - return; - } - } - try { - ws.send(msg); - } catch (err) { - console.log(err); - } - } - const sub = (filters, { - skipVerification = false, - alreadyHaveEvent = null, - id = Math.random().toString().slice(2) - } = {}) => { - let subid = id; - openSubs[subid] = { - id: subid, - filters, - skipVerification, - alreadyHaveEvent - }; - trySend(["REQ", subid, ...filters]); - return { - sub: (newFilters, newOpts = {}) => sub(newFilters || filters, { - skipVerification: newOpts.skipVerification || skipVerification, - alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent, - id: subid - }), - unsub: () => { - delete openSubs[subid]; - delete subListeners[subid]; - trySend(["CLOSE", subid]); - }, - on: (type, cb) => { - subListeners[subid] = subListeners[subid] || { - event: [], - eose: [] - }; - subListeners[subid][type].push(cb); - }, - off: (type, cb) => { - let listeners2 = subListeners[subid]; - let idx = listeners2[type].indexOf(cb); - if (idx >= 0) - listeners2[type].splice(idx, 1); - } - }; - }; - return { - url, - sub, - on: (type, cb) => { - listeners[type].push(cb); - if (type === "connect" && ws?.readyState === 1) { - cb(); - } - }, - off: (type, cb) => { - let index = listeners[type].indexOf(cb); - if (index !== -1) - listeners[type].splice(index, 1); - }, - list: (filters, opts) => new Promise((resolve) => { - let s = sub(filters, opts); - let events = []; - let timeout = setTimeout(() => { - s.unsub(); - resolve(events); - }, 1500); - s.on("eose", () => { - s.unsub(); - clearTimeout(timeout); - resolve(events); - }); - s.on("event", (event) => { - events.push(event); - }); - }), - get: (filter, opts) => new Promise((resolve) => { - let s = sub([filter], opts); - let timeout = setTimeout(() => { - s.unsub(); - resolve(null); - }, 1500); - s.on("event", (event) => { - s.unsub(); - clearTimeout(timeout); - resolve(event); - }); - }), - publish(event) { - if (!event.id) - throw new Error(`event ${event} has no id`); - let id = event.id; - trySend(["EVENT", event]); - return { - on: (type, cb) => { - pubListeners[id] = pubListeners[id] || { - ok: [], - failed: [] - }; - pubListeners[id][type].push(cb); - }, - off: (type, cb) => { - let listeners2 = pubListeners[id]; - if (!listeners2) - return; - let idx = listeners2[type].indexOf(cb); - if (idx >= 0) - listeners2[type].splice(idx, 1); - } - }; - }, - connect, - close() { - listeners = { connect: [], disconnect: [], error: [], notice: [] }; - subListeners = {}; - pubListeners = {}; - ws?.close(); - }, - get status() { - return ws?.readyState ?? 3; - } - }; - } - - // pool.ts - init_define_process(); - var SimplePool = class { - _conn; - _seenOn = {}; - eoseSubTimeout; - getTimeout; - constructor(options = {}) { - this._conn = {}; - this.eoseSubTimeout = options.eoseSubTimeout || 3400; - this.getTimeout = options.getTimeout || 3400; - } - close(relays) { - relays.forEach((url) => { - let relay = this._conn[normalizeURL(url)]; - if (relay) - relay.close(); - }); - } - async ensureRelay(url) { - const nm = normalizeURL(url); - const existing = this._conn[nm]; - if (existing) - return existing; - const relay = relayInit(nm); - this._conn[nm] = relay; - await relay.connect(); - return relay; - } - sub(relays, filters, opts) { - let _knownIds = /* @__PURE__ */ new Set(); - let modifiedOpts = opts || {}; - modifiedOpts.alreadyHaveEvent = (id, url) => { - let set = this._seenOn[id] || /* @__PURE__ */ new Set(); - set.add(url); - this._seenOn[id] = set; - return _knownIds.has(id); - }; - let subs = []; - let eventListeners = /* @__PURE__ */ new Set(); - let eoseListeners = /* @__PURE__ */ new Set(); - let eosesMissing = relays.length; - let eoseSent = false; - let eoseTimeout = setTimeout(() => { - eoseSent = true; - for (let cb of eoseListeners.values()) - cb(); - }, this.eoseSubTimeout); - relays.forEach(async (relay) => { - let r; - try { - r = await this.ensureRelay(relay); - } catch (err) { - handleEose(); - return; - } - if (!r) - return; - let s = r.sub(filters, modifiedOpts); - s.on("event", (event) => { - _knownIds.add(event.id); - for (let cb of eventListeners.values()) - cb(event); - }); - s.on("eose", () => { - if (eoseSent) - return; - handleEose(); - }); - subs.push(s); - function handleEose() { - eosesMissing--; - if (eosesMissing === 0) { - clearTimeout(eoseTimeout); - for (let cb of eoseListeners.values()) - cb(); - } - } - }); - let greaterSub = { - sub(filters2, opts2) { - subs.forEach((sub) => sub.sub(filters2, opts2)); - return greaterSub; - }, - unsub() { - subs.forEach((sub) => sub.unsub()); - }, - on(type, cb) { - switch (type) { - case "event": - eventListeners.add(cb); - break; - case "eose": - eoseListeners.add(cb); - break; - } - }, - off(type, cb) { - if (type === "event") { - eventListeners.delete(cb); - } else if (type === "eose") - eoseListeners.delete(cb); - } - }; - return greaterSub; - } - get(relays, filter, opts) { - return new Promise((resolve) => { - let sub = this.sub(relays, [filter], opts); - let timeout = setTimeout(() => { - sub.unsub(); - resolve(null); - }, this.getTimeout); - sub.on("event", (event) => { - resolve(event); - clearTimeout(timeout); - sub.unsub(); - }); - }); - } - list(relays, filters, opts) { - return new Promise((resolve) => { - let events = []; - let sub = this.sub(relays, filters, opts); - sub.on("event", (event) => { - events.push(event); - }); - sub.on("eose", () => { - sub.unsub(); - resolve(events); - }); - }); - } - publish(relays, event) { - let pubs = relays.map((relay) => { - let r = this._conn[normalizeURL(relay)]; - if (!r) - return badPub(relay); - return r.publish(event); - }); - return { - on(type, cb) { - pubs.forEach((pub, i) => { - pub.on(type, () => cb(relays[i])); - }); - }, - off() { - } - }; - } - seenOn(id) { - return Array.from(this._seenOn[id]?.values?.() || []); - } - }; - function badPub(relay) { - return { - on(typ, cb) { - if (typ === "failed") - cb(`relay ${relay} not connected`); - }, - off() { - } - }; - } - - // nip04.ts - var nip04_exports = {}; - __export(nip04_exports, { - decrypt: () => decrypt, - encrypt: () => encrypt - }); - init_define_process(); - - // node_modules/@scure/base/lib/esm/index.js - init_define_process(); - function assertNumber2(n) { - if (!Number.isSafeInteger(n)) - throw new Error(`Wrong integer: ${n}`); - } - function chain(...args) { - const wrap = (a, b) => (c) => a(b(c)); - const encode = Array.from(args).reverse().reduce((acc, i) => acc ? wrap(acc, i.encode) : i.encode, void 0); - const decode2 = args.reduce((acc, i) => acc ? wrap(acc, i.decode) : i.decode, void 0); - return { encode, decode: decode2 }; - } - function alphabet(alphabet2) { - return { - encode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("alphabet.encode input should be an array of numbers"); - return digits.map((i) => { - assertNumber2(i); - if (i < 0 || i >= alphabet2.length) - throw new Error(`Digit index outside alphabet: ${i} (alphabet: ${alphabet2.length})`); - return alphabet2[i]; - }); - }, - decode: (input) => { - if (!Array.isArray(input) || input.length && typeof input[0] !== "string") - throw new Error("alphabet.decode input should be array of strings"); - return input.map((letter) => { - if (typeof letter !== "string") - throw new Error(`alphabet.decode: not string element=${letter}`); - const index = alphabet2.indexOf(letter); - if (index === -1) - throw new Error(`Unknown letter: "${letter}". Allowed: ${alphabet2}`); - return index; - }); - } - }; - } - function join(separator = "") { - if (typeof separator !== "string") - throw new Error("join separator should be string"); - return { - encode: (from) => { - if (!Array.isArray(from) || from.length && typeof from[0] !== "string") - throw new Error("join.encode input should be array of strings"); - for (let i of from) - if (typeof i !== "string") - throw new Error(`join.encode: non-string input=${i}`); - return from.join(separator); - }, - decode: (to) => { - if (typeof to !== "string") - throw new Error("join.decode input should be string"); - return to.split(separator); - } - }; - } - function padding(bits, chr = "=") { - assertNumber2(bits); - if (typeof chr !== "string") - throw new Error("padding chr should be string"); - return { - encode(data) { - if (!Array.isArray(data) || data.length && typeof data[0] !== "string") - throw new Error("padding.encode input should be array of strings"); - for (let i of data) - if (typeof i !== "string") - throw new Error(`padding.encode: non-string input=${i}`); - while (data.length * bits % 8) - data.push(chr); - return data; - }, - decode(input) { - if (!Array.isArray(input) || input.length && typeof input[0] !== "string") - throw new Error("padding.encode input should be array of strings"); - for (let i of input) - if (typeof i !== "string") - throw new Error(`padding.decode: non-string input=${i}`); - let end = input.length; - if (end * bits % 8) - throw new Error("Invalid padding: string should have whole number of bytes"); - for (; end > 0 && input[end - 1] === chr; end--) { - if (!((end - 1) * bits % 8)) - throw new Error("Invalid padding: string has too much padding"); - } - return input.slice(0, end); - } - }; - } - function normalize(fn) { - if (typeof fn !== "function") - throw new Error("normalize fn should be function"); - return { encode: (from) => from, decode: (to) => fn(to) }; - } - function convertRadix(data, from, to) { - if (from < 2) - throw new Error(`convertRadix: wrong from=${from}, base cannot be less than 2`); - if (to < 2) - throw new Error(`convertRadix: wrong to=${to}, base cannot be less than 2`); - if (!Array.isArray(data)) - throw new Error("convertRadix: data should be array"); - if (!data.length) - return []; - let pos = 0; - const res = []; - const digits = Array.from(data); - digits.forEach((d) => { - assertNumber2(d); - if (d < 0 || d >= from) - throw new Error(`Wrong integer: ${d}`); - }); - while (true) { - let carry = 0; - let done = true; - for (let i = pos; i < digits.length; i++) { - const digit = digits[i]; - const digitBase = from * carry + digit; - if (!Number.isSafeInteger(digitBase) || from * carry / from !== carry || digitBase - digit !== from * carry) { - throw new Error("convertRadix: carry overflow"); - } - carry = digitBase % to; - digits[i] = Math.floor(digitBase / to); - if (!Number.isSafeInteger(digits[i]) || digits[i] * to + carry !== digitBase) - throw new Error("convertRadix: carry overflow"); - if (!done) - continue; - else if (!digits[i]) - pos = i; - else - done = false; - } - res.push(carry); - if (done) - break; - } - for (let i = 0; i < data.length - 1 && data[i] === 0; i++) - res.push(0); - return res.reverse(); - } - var gcd = (a, b) => !b ? a : gcd(b, a % b); - var radix2carry = (from, to) => from + (to - gcd(from, to)); - function convertRadix2(data, from, to, padding2) { - if (!Array.isArray(data)) - throw new Error("convertRadix2: data should be array"); - if (from <= 0 || from > 32) - throw new Error(`convertRadix2: wrong from=${from}`); - if (to <= 0 || to > 32) - throw new Error(`convertRadix2: wrong to=${to}`); - if (radix2carry(from, to) > 32) { - throw new Error(`convertRadix2: carry overflow from=${from} to=${to} carryBits=${radix2carry(from, to)}`); - } - let carry = 0; - let pos = 0; - const mask = 2 ** to - 1; - const res = []; - for (const n of data) { - assertNumber2(n); - if (n >= 2 ** from) - throw new Error(`convertRadix2: invalid data word=${n} from=${from}`); - carry = carry << from | n; - if (pos + from > 32) - throw new Error(`convertRadix2: carry overflow pos=${pos} from=${from}`); - pos += from; - for (; pos >= to; pos -= to) - res.push((carry >> pos - to & mask) >>> 0); - carry &= 2 ** pos - 1; - } - carry = carry << to - pos & mask; - if (!padding2 && pos >= from) - throw new Error("Excess padding"); - if (!padding2 && carry) - throw new Error(`Non-zero padding: ${carry}`); - if (padding2 && pos > 0) - res.push(carry >>> 0); - return res; - } - function radix(num) { - assertNumber2(num); - return { - encode: (bytes2) => { - if (!(bytes2 instanceof Uint8Array)) - throw new Error("radix.encode input should be Uint8Array"); - return convertRadix(Array.from(bytes2), 2 ** 8, num); - }, - decode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("radix.decode input should be array of strings"); - return Uint8Array.from(convertRadix(digits, num, 2 ** 8)); - } - }; - } - function radix2(bits, revPadding = false) { - assertNumber2(bits); - if (bits <= 0 || bits > 32) - throw new Error("radix2: bits should be in (0..32]"); - if (radix2carry(8, bits) > 32 || radix2carry(bits, 8) > 32) - throw new Error("radix2: carry overflow"); - return { - encode: (bytes2) => { - if (!(bytes2 instanceof Uint8Array)) - throw new Error("radix2.encode input should be Uint8Array"); - return convertRadix2(Array.from(bytes2), 8, bits, !revPadding); - }, - decode: (digits) => { - if (!Array.isArray(digits) || digits.length && typeof digits[0] !== "number") - throw new Error("radix2.decode input should be array of strings"); - return Uint8Array.from(convertRadix2(digits, bits, 8, revPadding)); - } - }; - } - function unsafeWrapper(fn) { - if (typeof fn !== "function") - throw new Error("unsafeWrapper fn should be function"); - return function(...args) { - try { - return fn.apply(null, args); - } catch (e) { - } - }; - } - function checksum(len, fn) { - assertNumber2(len); - if (typeof fn !== "function") - throw new Error("checksum fn should be function"); - return { - encode(data) { - if (!(data instanceof Uint8Array)) - throw new Error("checksum.encode: input should be Uint8Array"); - const checksum2 = fn(data).slice(0, len); - const res = new Uint8Array(data.length + len); - res.set(data); - res.set(checksum2, data.length); - return res; - }, - decode(data) { - if (!(data instanceof Uint8Array)) - throw new Error("checksum.decode: input should be Uint8Array"); - const payload = data.slice(0, -len); - const newChecksum = fn(payload).slice(0, len); - const oldChecksum = data.slice(-len); - for (let i = 0; i < len; i++) - if (newChecksum[i] !== oldChecksum[i]) - throw new Error("Invalid checksum"); - return payload; - } - }; - } - var base16 = chain(radix2(4), alphabet("0123456789ABCDEF"), join("")); - var base32 = chain(radix2(5), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), padding(5), join("")); - var base32hex = chain(radix2(5), alphabet("0123456789ABCDEFGHIJKLMNOPQRSTUV"), padding(5), join("")); - var base32crockford = chain(radix2(5), alphabet("0123456789ABCDEFGHJKMNPQRSTVWXYZ"), join(""), normalize((s) => s.toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1"))); - var base64 = chain(radix2(6), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), padding(6), join("")); - var base64url = chain(radix2(6), alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"), padding(6), join("")); - var genBase58 = (abc) => chain(radix(58), alphabet(abc), join("")); - var base58 = genBase58("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"); - var base58flickr = genBase58("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"); - var base58xrp = genBase58("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"); - var XMR_BLOCK_LEN = [0, 2, 3, 5, 6, 7, 9, 10, 11]; - var base58xmr = { - encode(data) { - let res = ""; - for (let i = 0; i < data.length; i += 8) { - const block = data.subarray(i, i + 8); - res += base58.encode(block).padStart(XMR_BLOCK_LEN[block.length], "1"); - } - return res; - }, - decode(str) { - let res = []; - for (let i = 0; i < str.length; i += 11) { - const slice = str.slice(i, i + 11); - const blockLen = XMR_BLOCK_LEN.indexOf(slice.length); - const block = base58.decode(slice); - for (let j = 0; j < block.length - blockLen; j++) { - if (block[j] !== 0) - throw new Error("base58xmr: wrong padding"); - } - res = res.concat(Array.from(block.slice(block.length - blockLen))); - } - return Uint8Array.from(res); - } - }; - var base58check = (sha2563) => chain(checksum(4, (data) => sha2563(sha2563(data))), base58); - var BECH_ALPHABET = chain(alphabet("qpzry9x8gf2tvdw0s3jn54khce6mua7l"), join("")); - var POLYMOD_GENERATORS = [996825010, 642813549, 513874426, 1027748829, 705979059]; - function bech32Polymod(pre) { - const b = pre >> 25; - let chk = (pre & 33554431) << 5; - for (let i = 0; i < POLYMOD_GENERATORS.length; i++) { - if ((b >> i & 1) === 1) - chk ^= POLYMOD_GENERATORS[i]; - } - return chk; - } - function bechChecksum(prefix, words, encodingConst = 1) { - const len = prefix.length; - let chk = 1; - for (let i = 0; i < len; i++) { - const c = prefix.charCodeAt(i); - if (c < 33 || c > 126) - throw new Error(`Invalid prefix (${prefix})`); - chk = bech32Polymod(chk) ^ c >> 5; - } - chk = bech32Polymod(chk); - for (let i = 0; i < len; i++) - chk = bech32Polymod(chk) ^ prefix.charCodeAt(i) & 31; - for (let v of words) - chk = bech32Polymod(chk) ^ v; - for (let i = 0; i < 6; i++) - chk = bech32Polymod(chk); - chk ^= encodingConst; - return BECH_ALPHABET.encode(convertRadix2([chk % 2 ** 30], 30, 5, false)); - } - function genBech32(encoding) { - const ENCODING_CONST = encoding === "bech32" ? 1 : 734539939; - const _words = radix2(5); - const fromWords = _words.decode; - const toWords = _words.encode; - const fromWordsUnsafe = unsafeWrapper(fromWords); - function encode(prefix, words, limit = 90) { - if (typeof prefix !== "string") - throw new Error(`bech32.encode prefix should be string, not ${typeof prefix}`); - if (!Array.isArray(words) || words.length && typeof words[0] !== "number") - throw new Error(`bech32.encode words should be array of numbers, not ${typeof words}`); - const actualLength = prefix.length + 7 + words.length; - if (limit !== false && actualLength > limit) - throw new TypeError(`Length ${actualLength} exceeds limit ${limit}`); - prefix = prefix.toLowerCase(); - return `${prefix}1${BECH_ALPHABET.encode(words)}${bechChecksum(prefix, words, ENCODING_CONST)}`; - } - function decode2(str, limit = 90) { - if (typeof str !== "string") - throw new Error(`bech32.decode input should be string, not ${typeof str}`); - if (str.length < 8 || limit !== false && str.length > limit) - throw new TypeError(`Wrong string length: ${str.length} (${str}). Expected (8..${limit})`); - const lowered = str.toLowerCase(); - if (str !== lowered && str !== str.toUpperCase()) - throw new Error(`String must be lowercase or uppercase`); - str = lowered; - const sepIndex = str.lastIndexOf("1"); - if (sepIndex === 0 || sepIndex === -1) - throw new Error(`Letter "1" must be present between prefix and data only`); - const prefix = str.slice(0, sepIndex); - const _words2 = str.slice(sepIndex + 1); - if (_words2.length < 6) - throw new Error("Data must be at least 6 characters long"); - const words = BECH_ALPHABET.decode(_words2).slice(0, -6); - const sum = bechChecksum(prefix, words, ENCODING_CONST); - if (!_words2.endsWith(sum)) - throw new Error(`Invalid checksum in ${str}: expected "${sum}"`); - return { prefix, words }; - } - const decodeUnsafe = unsafeWrapper(decode2); - function decodeToBytes(str) { - const { prefix, words } = decode2(str, false); - return { prefix, words, bytes: fromWords(words) }; - } - return { encode, decode: decode2, decodeToBytes, decodeUnsafe, fromWords, fromWordsUnsafe, toWords }; - } - var bech32 = genBech32("bech32"); - var bech32m = genBech32("bech32m"); - var utf8 = { - encode: (data) => new TextDecoder().decode(data), - decode: (str) => new TextEncoder().encode(str) - }; - var hex = chain(radix2(4), alphabet("0123456789abcdef"), join(""), normalize((s) => { - if (typeof s !== "string" || s.length % 2) - throw new TypeError(`hex.decode: expected string, got ${typeof s} with length ${s.length}`); - return s.toLowerCase(); - })); - var CODERS = { - utf8, - hex, - base16, - base32, - base64, - base64url, - base58, - base58xmr - }; - var coderTypeError = `Invalid encoding type. Available types: ${Object.keys(CODERS).join(", ")}`; - - // nip04.ts - async function encrypt(privkey, pubkey, text) { - const key = getSharedSecret(privkey, "02" + pubkey); - const normalizedKey = getNormalizedX(key); - let iv = Uint8Array.from(randomBytes(16)); - let plaintext = utf8Encoder.encode(text); - let cryptoKey = await crypto.subtle.importKey( - "raw", - normalizedKey, - { name: "AES-CBC" }, - false, - ["encrypt"] - ); - let ciphertext = await crypto.subtle.encrypt( - { name: "AES-CBC", iv }, - cryptoKey, - plaintext - ); - let ctb64 = base64.encode(new Uint8Array(ciphertext)); - let ivb64 = base64.encode(new Uint8Array(iv.buffer)); - return `${ctb64}?iv=${ivb64}`; - } - async function decrypt(privkey, pubkey, data) { - let [ctb64, ivb64] = data.split("?iv="); - let key = getSharedSecret(privkey, "02" + pubkey); - let normalizedKey = getNormalizedX(key); - let cryptoKey = await crypto.subtle.importKey( - "raw", - normalizedKey, - { name: "AES-CBC" }, - false, - ["decrypt"] - ); - let ciphertext = base64.decode(ctb64); - let iv = base64.decode(ivb64); - let plaintext = await crypto.subtle.decrypt( - { name: "AES-CBC", iv }, - cryptoKey, - ciphertext - ); - let text = utf8Decoder.decode(plaintext); - return text; - } - function getNormalizedX(key) { - return key.slice(1, 33); - } - - // nip05.ts - var nip05_exports = {}; - __export(nip05_exports, { - queryProfile: () => queryProfile, - searchDomain: () => searchDomain, - useFetchImplementation: () => useFetchImplementation - }); - init_define_process(); - var _fetch; - try { - _fetch = fetch; - } catch { - } - function useFetchImplementation(fetchImplementation) { - _fetch = fetchImplementation; - } - async function searchDomain(domain, query = "") { - try { - let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json(); - return res.names; - } catch (_) { - return {}; - } - } - async function queryProfile(fullname) { - let [name, domain] = fullname.split("@"); - if (!domain) { - domain = name; - name = "_"; - } - if (!name.match(/^[A-Za-z0-9-_]+$/)) - return null; - let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)).json(); - if (!res?.names?.[name]) - return null; - let pubkey = res.names[name]; - let relays = res.relays?.[pubkey] || []; - return { - pubkey, - relays - }; - } - - // nip06.ts - var nip06_exports = {}; - __export(nip06_exports, { - generateSeedWords: () => generateSeedWords, - privateKeyFromSeedWords: () => privateKeyFromSeedWords, - validateWords: () => validateWords - }); - init_define_process(); - var import_english = __toESM(require_english()); - var import_bip39 = __toESM(require_bip39()); - - // node_modules/@scure/bip32/lib/esm/index.js - init_define_process(); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/hmac.js - init_define_process(); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/_assert.js - init_define_process(); - function number(n) { - if (!Number.isSafeInteger(n) || n < 0) - throw new Error(`Wrong positive integer: ${n}`); - } - function bool(b) { - if (typeof b !== "boolean") - throw new Error(`Expected boolean, not ${b}`); - } - function bytes(b, ...lengths) { - if (!(b instanceof Uint8Array)) - throw new TypeError("Expected Uint8Array"); - if (lengths.length > 0 && !lengths.includes(b.length)) - throw new TypeError(`Expected Uint8Array of length ${lengths}, not of length=${b.length}`); - } - function hash(hash2) { - if (typeof hash2 !== "function" || typeof hash2.create !== "function") - throw new Error("Hash should be wrapped by utils.wrapConstructor"); - number(hash2.outputLen); - number(hash2.blockLen); - } - function exists(instance, checkFinished = true) { - if (instance.destroyed) - throw new Error("Hash instance has been destroyed"); - if (checkFinished && instance.finished) - throw new Error("Hash#digest() has already been called"); - } - function output(out, instance) { - bytes(out); - const min = instance.outputLen; - if (out.length < min) { - throw new Error(`digestInto() expects output buffer of length at least ${min}`); - } - } - var assert = { - number, - bool, - bytes, - hash, - exists, - output - }; - var assert_default = assert; - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/utils.js - init_define_process(); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/cryptoBrowser.js - init_define_process(); - var crypto4 = { - node: void 0, - web: typeof self === "object" && "crypto" in self ? self.crypto : void 0 - }; - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/utils.js - var createView2 = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength); - var rotr2 = (word, shift) => word << 32 - shift | word >>> shift; - var isLE2 = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68; - if (!isLE2) - throw new Error("Non little-endian hardware is not supported"); - var hexes3 = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, "0")); - function bytesToHex2(uint8a) { - if (!(uint8a instanceof Uint8Array)) - throw new Error("Uint8Array expected"); - let hex2 = ""; - for (let i = 0; i < uint8a.length; i++) { - hex2 += hexes3[uint8a[i]]; - } - return hex2; - } - function hexToBytes2(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToBytes: expected string, got " + typeof hex2); - } - if (hex2.length % 2) - throw new Error("hexToBytes: received invalid unpadded hex"); - const array = new Uint8Array(hex2.length / 2); - for (let i = 0; i < array.length; i++) { - const j = i * 2; - const hexByte = hex2.slice(j, j + 2); - const byte = Number.parseInt(hexByte, 16); - if (Number.isNaN(byte) || byte < 0) - throw new Error("Invalid byte sequence"); - array[i] = byte; - } - return array; - } - function utf8ToBytes2(str) { - if (typeof str !== "string") { - throw new TypeError(`utf8ToBytes expected string, got ${typeof str}`); - } - return new TextEncoder().encode(str); - } - function toBytes2(data) { - if (typeof data === "string") - data = utf8ToBytes2(data); - if (!(data instanceof Uint8Array)) - throw new TypeError(`Expected input type is Uint8Array (got ${typeof data})`); - return data; - } - function concatBytes2(...arrays) { - if (!arrays.every((a) => a instanceof Uint8Array)) - throw new Error("Uint8Array list expected"); - if (arrays.length === 1) - return arrays[0]; - const length = arrays.reduce((a, arr) => a + arr.length, 0); - const result = new Uint8Array(length); - for (let i = 0, pad = 0; i < arrays.length; i++) { - const arr = arrays[i]; - result.set(arr, pad); - pad += arr.length; - } - return result; - } - var Hash2 = class { - clone() { - return this._cloneInto(); - } - }; - function wrapConstructor2(hashConstructor) { - const hashC = (message) => hashConstructor().update(toBytes2(message)).digest(); - const tmp = hashConstructor(); - hashC.outputLen = tmp.outputLen; - hashC.blockLen = tmp.blockLen; - hashC.create = () => hashConstructor(); - return hashC; - } - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/hmac.js - var HMAC = class extends Hash2 { - constructor(hash2, _key) { - super(); - this.finished = false; - this.destroyed = false; - assert_default.hash(hash2); - const key = toBytes2(_key); - this.iHash = hash2.create(); - if (typeof this.iHash.update !== "function") - throw new TypeError("Expected instance of class which extends utils.Hash"); - this.blockLen = this.iHash.blockLen; - this.outputLen = this.iHash.outputLen; - const blockLen = this.blockLen; - const pad = new Uint8Array(blockLen); - pad.set(key.length > blockLen ? hash2.create().update(key).digest() : key); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54; - this.iHash.update(pad); - this.oHash = hash2.create(); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54 ^ 92; - this.oHash.update(pad); - pad.fill(0); - } - update(buf) { - assert_default.exists(this); - this.iHash.update(buf); - return this; - } - digestInto(out) { - assert_default.exists(this); - assert_default.bytes(out, this.outputLen); - this.finished = true; - this.iHash.digestInto(out); - this.oHash.update(out); - this.oHash.digestInto(out); - this.destroy(); - } - digest() { - const out = new Uint8Array(this.oHash.outputLen); - this.digestInto(out); - return out; - } - _cloneInto(to) { - to || (to = Object.create(Object.getPrototypeOf(this), {})); - const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this; - to = to; - to.finished = finished; - to.destroyed = destroyed; - to.blockLen = blockLen; - to.outputLen = outputLen; - to.oHash = oHash._cloneInto(to.oHash); - to.iHash = iHash._cloneInto(to.iHash); - return to; - } - destroy() { - this.destroyed = true; - this.oHash.destroy(); - this.iHash.destroy(); - } - }; - var hmac = (hash2, key, message) => new HMAC(hash2, key).update(message).digest(); - hmac.create = (hash2, key) => new HMAC(hash2, key); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/ripemd160.js - init_define_process(); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/_sha2.js - init_define_process(); - function setBigUint642(view, byteOffset, value, isLE3) { - if (typeof view.setBigUint64 === "function") - return view.setBigUint64(byteOffset, value, isLE3); - const _32n2 = BigInt(32); - const _u32_max = BigInt(4294967295); - const wh = Number(value >> _32n2 & _u32_max); - const wl = Number(value & _u32_max); - const h = isLE3 ? 4 : 0; - const l = isLE3 ? 0 : 4; - view.setUint32(byteOffset + h, wh, isLE3); - view.setUint32(byteOffset + l, wl, isLE3); - } - var SHA22 = class extends Hash2 { - constructor(blockLen, outputLen, padOffset, isLE3) { - super(); - this.blockLen = blockLen; - this.outputLen = outputLen; - this.padOffset = padOffset; - this.isLE = isLE3; - this.finished = false; - this.length = 0; - this.pos = 0; - this.destroyed = false; - this.buffer = new Uint8Array(blockLen); - this.view = createView2(this.buffer); - } - update(data) { - assert_default.exists(this); - const { view, buffer, blockLen } = this; - data = toBytes2(data); - const len = data.length; - for (let pos = 0; pos < len; ) { - const take = Math.min(blockLen - this.pos, len - pos); - if (take === blockLen) { - const dataView = createView2(data); - for (; blockLen <= len - pos; pos += blockLen) - this.process(dataView, pos); - continue; - } - buffer.set(data.subarray(pos, pos + take), this.pos); - this.pos += take; - pos += take; - if (this.pos === blockLen) { - this.process(view, 0); - this.pos = 0; - } - } - this.length += data.length; - this.roundClean(); - return this; - } - digestInto(out) { - assert_default.exists(this); - assert_default.output(out, this); - this.finished = true; - const { buffer, view, blockLen, isLE: isLE3 } = this; - let { pos } = this; - buffer[pos++] = 128; - this.buffer.subarray(pos).fill(0); - if (this.padOffset > blockLen - pos) { - this.process(view, 0); - pos = 0; - } - for (let i = pos; i < blockLen; i++) - buffer[i] = 0; - setBigUint642(view, blockLen - 8, BigInt(this.length * 8), isLE3); - this.process(view, 0); - const oview = createView2(out); - const len = this.outputLen; - if (len % 4) - throw new Error("_sha2: outputLen should be aligned to 32bit"); - const outLen = len / 4; - const state = this.get(); - if (outLen > state.length) - throw new Error("_sha2: outputLen bigger than state"); - for (let i = 0; i < outLen; i++) - oview.setUint32(4 * i, state[i], isLE3); - } - digest() { - const { buffer, outputLen } = this; - this.digestInto(buffer); - const res = buffer.slice(0, outputLen); - this.destroy(); - return res; - } - _cloneInto(to) { - to || (to = new this.constructor()); - to.set(...this.get()); - const { blockLen, buffer, length, finished, destroyed, pos } = this; - to.length = length; - to.pos = pos; - to.finished = finished; - to.destroyed = destroyed; - if (length % blockLen) - to.buffer.set(buffer); - return to; - } - }; - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/ripemd160.js - var Rho = new Uint8Array([7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8]); - var Id = Uint8Array.from({ length: 16 }, (_, i) => i); - var Pi = Id.map((i) => (9 * i + 5) % 16); - var idxL = [Id]; - var idxR = [Pi]; - for (let i = 0; i < 4; i++) - for (let j of [idxL, idxR]) - j.push(j[i].map((k) => Rho[k])); - var shifts = [ - [11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8], - [12, 13, 11, 15, 6, 9, 9, 7, 12, 15, 11, 13, 7, 8, 7, 7], - [13, 15, 14, 11, 7, 7, 6, 8, 13, 14, 13, 12, 5, 5, 6, 9], - [14, 11, 12, 14, 8, 6, 5, 5, 15, 12, 15, 14, 9, 9, 8, 6], - [15, 12, 13, 13, 9, 5, 8, 6, 14, 11, 12, 11, 8, 6, 5, 5] - ].map((i) => new Uint8Array(i)); - var shiftsL = idxL.map((idx, i) => idx.map((j) => shifts[i][j])); - var shiftsR = idxR.map((idx, i) => idx.map((j) => shifts[i][j])); - var Kl = new Uint32Array([0, 1518500249, 1859775393, 2400959708, 2840853838]); - var Kr = new Uint32Array([1352829926, 1548603684, 1836072691, 2053994217, 0]); - var rotl = (word, shift) => word << shift | word >>> 32 - shift; - function f(group, x, y, z) { - if (group === 0) - return x ^ y ^ z; - else if (group === 1) - return x & y | ~x & z; - else if (group === 2) - return (x | ~y) ^ z; - else if (group === 3) - return x & z | y & ~z; - else - return x ^ (y | ~z); - } - var BUF = new Uint32Array(16); - var RIPEMD160 = class extends SHA22 { - constructor() { - super(64, 20, 8, true); - this.h0 = 1732584193 | 0; - this.h1 = 4023233417 | 0; - this.h2 = 2562383102 | 0; - this.h3 = 271733878 | 0; - this.h4 = 3285377520 | 0; - } - get() { - const { h0, h1, h2, h3, h4 } = this; - return [h0, h1, h2, h3, h4]; - } - set(h0, h1, h2, h3, h4) { - this.h0 = h0 | 0; - this.h1 = h1 | 0; - this.h2 = h2 | 0; - this.h3 = h3 | 0; - this.h4 = h4 | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) - BUF[i] = view.getUint32(offset, true); - let al = this.h0 | 0, ar = al, bl = this.h1 | 0, br = bl, cl = this.h2 | 0, cr = cl, dl = this.h3 | 0, dr = dl, el = this.h4 | 0, er = el; - for (let group = 0; group < 5; group++) { - const rGroup = 4 - group; - const hbl = Kl[group], hbr = Kr[group]; - const rl = idxL[group], rr = idxR[group]; - const sl = shiftsL[group], sr = shiftsR[group]; - for (let i = 0; i < 16; i++) { - const tl = rotl(al + f(group, bl, cl, dl) + BUF[rl[i]] + hbl, sl[i]) + el | 0; - al = el, el = dl, dl = rotl(cl, 10) | 0, cl = bl, bl = tl; - } - for (let i = 0; i < 16; i++) { - const tr = rotl(ar + f(rGroup, br, cr, dr) + BUF[rr[i]] + hbr, sr[i]) + er | 0; - ar = er, er = dr, dr = rotl(cr, 10) | 0, cr = br, br = tr; - } - } - this.set(this.h1 + cl + dr | 0, this.h2 + dl + er | 0, this.h3 + el + ar | 0, this.h4 + al + br | 0, this.h0 + bl + cr | 0); - } - roundClean() { - BUF.fill(0); - } - destroy() { - this.destroyed = true; - this.buffer.fill(0); - this.set(0, 0, 0, 0, 0); - } - }; - var ripemd160 = wrapConstructor2(() => new RIPEMD160()); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/sha256.js - init_define_process(); - var Chi2 = (a, b, c) => a & b ^ ~a & c; - var Maj2 = (a, b, c) => a & b ^ a & c ^ b & c; - var SHA256_K2 = new Uint32Array([ - 1116352408, - 1899447441, - 3049323471, - 3921009573, - 961987163, - 1508970993, - 2453635748, - 2870763221, - 3624381080, - 310598401, - 607225278, - 1426881987, - 1925078388, - 2162078206, - 2614888103, - 3248222580, - 3835390401, - 4022224774, - 264347078, - 604807628, - 770255983, - 1249150122, - 1555081692, - 1996064986, - 2554220882, - 2821834349, - 2952996808, - 3210313671, - 3336571891, - 3584528711, - 113926993, - 338241895, - 666307205, - 773529912, - 1294757372, - 1396182291, - 1695183700, - 1986661051, - 2177026350, - 2456956037, - 2730485921, - 2820302411, - 3259730800, - 3345764771, - 3516065817, - 3600352804, - 4094571909, - 275423344, - 430227734, - 506948616, - 659060556, - 883997877, - 958139571, - 1322822218, - 1537002063, - 1747873779, - 1955562222, - 2024104815, - 2227730452, - 2361852424, - 2428436474, - 2756734187, - 3204031479, - 3329325298 - ]); - var IV2 = new Uint32Array([ - 1779033703, - 3144134277, - 1013904242, - 2773480762, - 1359893119, - 2600822924, - 528734635, - 1541459225 - ]); - var SHA256_W2 = new Uint32Array(64); - var SHA2562 = class extends SHA22 { - constructor() { - super(64, 32, 8, false); - this.A = IV2[0] | 0; - this.B = IV2[1] | 0; - this.C = IV2[2] | 0; - this.D = IV2[3] | 0; - this.E = IV2[4] | 0; - this.F = IV2[5] | 0; - this.G = IV2[6] | 0; - this.H = IV2[7] | 0; - } - get() { - const { A, B, C, D, E, F, G, H } = this; - return [A, B, C, D, E, F, G, H]; - } - set(A, B, C, D, E, F, G, H) { - this.A = A | 0; - this.B = B | 0; - this.C = C | 0; - this.D = D | 0; - this.E = E | 0; - this.F = F | 0; - this.G = G | 0; - this.H = H | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) - SHA256_W2[i] = view.getUint32(offset, false); - for (let i = 16; i < 64; i++) { - const W15 = SHA256_W2[i - 15]; - const W2 = SHA256_W2[i - 2]; - const s0 = rotr2(W15, 7) ^ rotr2(W15, 18) ^ W15 >>> 3; - const s1 = rotr2(W2, 17) ^ rotr2(W2, 19) ^ W2 >>> 10; - SHA256_W2[i] = s1 + SHA256_W2[i - 7] + s0 + SHA256_W2[i - 16] | 0; - } - let { A, B, C, D, E, F, G, H } = this; - for (let i = 0; i < 64; i++) { - const sigma1 = rotr2(E, 6) ^ rotr2(E, 11) ^ rotr2(E, 25); - const T1 = H + sigma1 + Chi2(E, F, G) + SHA256_K2[i] + SHA256_W2[i] | 0; - const sigma0 = rotr2(A, 2) ^ rotr2(A, 13) ^ rotr2(A, 22); - const T2 = sigma0 + Maj2(A, B, C) | 0; - H = G; - G = F; - F = E; - E = D + T1 | 0; - D = C; - C = B; - B = A; - A = T1 + T2 | 0; - } - A = A + this.A | 0; - B = B + this.B | 0; - C = C + this.C | 0; - D = D + this.D | 0; - E = E + this.E | 0; - F = F + this.F | 0; - G = G + this.G | 0; - H = H + this.H | 0; - this.set(A, B, C, D, E, F, G, H); - } - roundClean() { - SHA256_W2.fill(0); - } - destroy() { - this.set(0, 0, 0, 0, 0, 0, 0, 0); - this.buffer.fill(0); - } - }; - var SHA224 = class extends SHA2562 { - constructor() { - super(); - this.A = 3238371032 | 0; - this.B = 914150663 | 0; - this.C = 812702999 | 0; - this.D = 4144912697 | 0; - this.E = 4290775857 | 0; - this.F = 1750603025 | 0; - this.G = 1694076839 | 0; - this.H = 3204075428 | 0; - this.outputLen = 28; - } - }; - var sha2562 = wrapConstructor2(() => new SHA2562()); - var sha224 = wrapConstructor2(() => new SHA224()); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/sha512.js - init_define_process(); - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/_u64.js - init_define_process(); - var U32_MASK64 = BigInt(2 ** 32 - 1); - var _32n = BigInt(32); - function fromBig(n, le = false) { - if (le) - return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) }; - return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 }; - } - function split(lst, le = false) { - let Ah = new Uint32Array(lst.length); - let Al = new Uint32Array(lst.length); - for (let i = 0; i < lst.length; i++) { - const { h, l } = fromBig(lst[i], le); - [Ah[i], Al[i]] = [h, l]; - } - return [Ah, Al]; - } - var toBig = (h, l) => BigInt(h >>> 0) << _32n | BigInt(l >>> 0); - var shrSH = (h, l, s) => h >>> s; - var shrSL = (h, l, s) => h << 32 - s | l >>> s; - var rotrSH = (h, l, s) => h >>> s | l << 32 - s; - var rotrSL = (h, l, s) => h << 32 - s | l >>> s; - var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32; - var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s; - var rotr32H = (h, l) => l; - var rotr32L = (h, l) => h; - var rotlSH = (h, l, s) => h << s | l >>> 32 - s; - var rotlSL = (h, l, s) => l << s | h >>> 32 - s; - var rotlBH = (h, l, s) => l << s - 32 | h >>> 64 - s; - var rotlBL = (h, l, s) => h << s - 32 | l >>> 64 - s; - function add(Ah, Al, Bh, Bl) { - const l = (Al >>> 0) + (Bl >>> 0); - return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 }; - } - var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0); - var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0; - var add4L = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0); - var add4H = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0; - var add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0); - var add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0; - var u64 = { - fromBig, - split, - toBig, - shrSH, - shrSL, - rotrSH, - rotrSL, - rotrBH, - rotrBL, - rotr32H, - rotr32L, - rotlSH, - rotlSL, - rotlBH, - rotlBL, - add, - add3L, - add3H, - add4L, - add4H, - add5H, - add5L - }; - var u64_default = u64; - - // node_modules/@scure/bip32/node_modules/@noble/hashes/esm/sha512.js - var [SHA512_Kh, SHA512_Kl] = u64_default.split([ - "0x428a2f98d728ae22", - "0x7137449123ef65cd", - "0xb5c0fbcfec4d3b2f", - "0xe9b5dba58189dbbc", - "0x3956c25bf348b538", - "0x59f111f1b605d019", - "0x923f82a4af194f9b", - "0xab1c5ed5da6d8118", - "0xd807aa98a3030242", - "0x12835b0145706fbe", - "0x243185be4ee4b28c", - "0x550c7dc3d5ffb4e2", - "0x72be5d74f27b896f", - "0x80deb1fe3b1696b1", - "0x9bdc06a725c71235", - "0xc19bf174cf692694", - "0xe49b69c19ef14ad2", - "0xefbe4786384f25e3", - "0x0fc19dc68b8cd5b5", - "0x240ca1cc77ac9c65", - "0x2de92c6f592b0275", - "0x4a7484aa6ea6e483", - "0x5cb0a9dcbd41fbd4", - "0x76f988da831153b5", - "0x983e5152ee66dfab", - "0xa831c66d2db43210", - "0xb00327c898fb213f", - "0xbf597fc7beef0ee4", - "0xc6e00bf33da88fc2", - "0xd5a79147930aa725", - "0x06ca6351e003826f", - "0x142929670a0e6e70", - "0x27b70a8546d22ffc", - "0x2e1b21385c26c926", - "0x4d2c6dfc5ac42aed", - "0x53380d139d95b3df", - "0x650a73548baf63de", - "0x766a0abb3c77b2a8", - "0x81c2c92e47edaee6", - "0x92722c851482353b", - "0xa2bfe8a14cf10364", - "0xa81a664bbc423001", - "0xc24b8b70d0f89791", - "0xc76c51a30654be30", - "0xd192e819d6ef5218", - "0xd69906245565a910", - "0xf40e35855771202a", - "0x106aa07032bbd1b8", - "0x19a4c116b8d2d0c8", - "0x1e376c085141ab53", - "0x2748774cdf8eeb99", - "0x34b0bcb5e19b48a8", - "0x391c0cb3c5c95a63", - "0x4ed8aa4ae3418acb", - "0x5b9cca4f7763e373", - "0x682e6ff3d6b2b8a3", - "0x748f82ee5defb2fc", - "0x78a5636f43172f60", - "0x84c87814a1f0ab72", - "0x8cc702081a6439ec", - "0x90befffa23631e28", - "0xa4506cebde82bde9", - "0xbef9a3f7b2c67915", - "0xc67178f2e372532b", - "0xca273eceea26619c", - "0xd186b8c721c0c207", - "0xeada7dd6cde0eb1e", - "0xf57d4f7fee6ed178", - "0x06f067aa72176fba", - "0x0a637dc5a2c898a6", - "0x113f9804bef90dae", - "0x1b710b35131c471b", - "0x28db77f523047d84", - "0x32caab7b40c72493", - "0x3c9ebe0a15c9bebc", - "0x431d67c49c100d4c", - "0x4cc5d4becb3e42b6", - "0x597f299cfc657e2a", - "0x5fcb6fab3ad6faec", - "0x6c44198c4a475817" - ].map((n) => BigInt(n))); - var SHA512_W_H = new Uint32Array(80); - var SHA512_W_L = new Uint32Array(80); - var SHA512 = class extends SHA22 { - constructor() { - super(128, 64, 16, false); - this.Ah = 1779033703 | 0; - this.Al = 4089235720 | 0; - this.Bh = 3144134277 | 0; - this.Bl = 2227873595 | 0; - this.Ch = 1013904242 | 0; - this.Cl = 4271175723 | 0; - this.Dh = 2773480762 | 0; - this.Dl = 1595750129 | 0; - this.Eh = 1359893119 | 0; - this.El = 2917565137 | 0; - this.Fh = 2600822924 | 0; - this.Fl = 725511199 | 0; - this.Gh = 528734635 | 0; - this.Gl = 4215389547 | 0; - this.Hh = 1541459225 | 0; - this.Hl = 327033209 | 0; - } - get() { - const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this; - return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl]; - } - set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) { - this.Ah = Ah | 0; - this.Al = Al | 0; - this.Bh = Bh | 0; - this.Bl = Bl | 0; - this.Ch = Ch | 0; - this.Cl = Cl | 0; - this.Dh = Dh | 0; - this.Dl = Dl | 0; - this.Eh = Eh | 0; - this.El = El | 0; - this.Fh = Fh | 0; - this.Fl = Fl | 0; - this.Gh = Gh | 0; - this.Gl = Gl | 0; - this.Hh = Hh | 0; - this.Hl = Hl | 0; - } - process(view, offset) { - for (let i = 0; i < 16; i++, offset += 4) { - SHA512_W_H[i] = view.getUint32(offset); - SHA512_W_L[i] = view.getUint32(offset += 4); - } - for (let i = 16; i < 80; i++) { - const W15h = SHA512_W_H[i - 15] | 0; - const W15l = SHA512_W_L[i - 15] | 0; - const s0h = u64_default.rotrSH(W15h, W15l, 1) ^ u64_default.rotrSH(W15h, W15l, 8) ^ u64_default.shrSH(W15h, W15l, 7); - const s0l = u64_default.rotrSL(W15h, W15l, 1) ^ u64_default.rotrSL(W15h, W15l, 8) ^ u64_default.shrSL(W15h, W15l, 7); - const W2h = SHA512_W_H[i - 2] | 0; - const W2l = SHA512_W_L[i - 2] | 0; - const s1h = u64_default.rotrSH(W2h, W2l, 19) ^ u64_default.rotrBH(W2h, W2l, 61) ^ u64_default.shrSH(W2h, W2l, 6); - const s1l = u64_default.rotrSL(W2h, W2l, 19) ^ u64_default.rotrBL(W2h, W2l, 61) ^ u64_default.shrSL(W2h, W2l, 6); - const SUMl = u64_default.add4L(s0l, s1l, SHA512_W_L[i - 7], SHA512_W_L[i - 16]); - const SUMh = u64_default.add4H(SUMl, s0h, s1h, SHA512_W_H[i - 7], SHA512_W_H[i - 16]); - SHA512_W_H[i] = SUMh | 0; - SHA512_W_L[i] = SUMl | 0; - } - let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this; - for (let i = 0; i < 80; i++) { - const sigma1h = u64_default.rotrSH(Eh, El, 14) ^ u64_default.rotrSH(Eh, El, 18) ^ u64_default.rotrBH(Eh, El, 41); - const sigma1l = u64_default.rotrSL(Eh, El, 14) ^ u64_default.rotrSL(Eh, El, 18) ^ u64_default.rotrBL(Eh, El, 41); - const CHIh = Eh & Fh ^ ~Eh & Gh; - const CHIl = El & Fl ^ ~El & Gl; - const T1ll = u64_default.add5L(Hl, sigma1l, CHIl, SHA512_Kl[i], SHA512_W_L[i]); - const T1h = u64_default.add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh[i], SHA512_W_H[i]); - const T1l = T1ll | 0; - const sigma0h = u64_default.rotrSH(Ah, Al, 28) ^ u64_default.rotrBH(Ah, Al, 34) ^ u64_default.rotrBH(Ah, Al, 39); - const sigma0l = u64_default.rotrSL(Ah, Al, 28) ^ u64_default.rotrBL(Ah, Al, 34) ^ u64_default.rotrBL(Ah, Al, 39); - const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch; - const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl; - Hh = Gh | 0; - Hl = Gl | 0; - Gh = Fh | 0; - Gl = Fl | 0; - Fh = Eh | 0; - Fl = El | 0; - ({ h: Eh, l: El } = u64_default.add(Dh | 0, Dl | 0, T1h | 0, T1l | 0)); - Dh = Ch | 0; - Dl = Cl | 0; - Ch = Bh | 0; - Cl = Bl | 0; - Bh = Ah | 0; - Bl = Al | 0; - const All = u64_default.add3L(T1l, sigma0l, MAJl); - Ah = u64_default.add3H(All, T1h, sigma0h, MAJh); - Al = All | 0; - } - ({ h: Ah, l: Al } = u64_default.add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0)); - ({ h: Bh, l: Bl } = u64_default.add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0)); - ({ h: Ch, l: Cl } = u64_default.add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0)); - ({ h: Dh, l: Dl } = u64_default.add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0)); - ({ h: Eh, l: El } = u64_default.add(this.Eh | 0, this.El | 0, Eh | 0, El | 0)); - ({ h: Fh, l: Fl } = u64_default.add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0)); - ({ h: Gh, l: Gl } = u64_default.add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0)); - ({ h: Hh, l: Hl } = u64_default.add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0)); - this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl); - } - roundClean() { - SHA512_W_H.fill(0); - SHA512_W_L.fill(0); - } - destroy() { - this.buffer.fill(0); - this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - } - }; - var SHA512_224 = class extends SHA512 { - constructor() { - super(); - this.Ah = 2352822216 | 0; - this.Al = 424955298 | 0; - this.Bh = 1944164710 | 0; - this.Bl = 2312950998 | 0; - this.Ch = 502970286 | 0; - this.Cl = 855612546 | 0; - this.Dh = 1738396948 | 0; - this.Dl = 1479516111 | 0; - this.Eh = 258812777 | 0; - this.El = 2077511080 | 0; - this.Fh = 2011393907 | 0; - this.Fl = 79989058 | 0; - this.Gh = 1067287976 | 0; - this.Gl = 1780299464 | 0; - this.Hh = 286451373 | 0; - this.Hl = 2446758561 | 0; - this.outputLen = 28; - } - }; - var SHA512_256 = class extends SHA512 { - constructor() { - super(); - this.Ah = 573645204 | 0; - this.Al = 4230739756 | 0; - this.Bh = 2673172387 | 0; - this.Bl = 3360449730 | 0; - this.Ch = 596883563 | 0; - this.Cl = 1867755857 | 0; - this.Dh = 2520282905 | 0; - this.Dl = 1497426621 | 0; - this.Eh = 2519219938 | 0; - this.El = 2827943907 | 0; - this.Fh = 3193839141 | 0; - this.Fl = 1401305490 | 0; - this.Gh = 721525244 | 0; - this.Gl = 746961066 | 0; - this.Hh = 246885852 | 0; - this.Hl = 2177182882 | 0; - this.outputLen = 32; - } - }; - var SHA384 = class extends SHA512 { - constructor() { - super(); - this.Ah = 3418070365 | 0; - this.Al = 3238371032 | 0; - this.Bh = 1654270250 | 0; - this.Bl = 914150663 | 0; - this.Ch = 2438529370 | 0; - this.Cl = 812702999 | 0; - this.Dh = 355462360 | 0; - this.Dl = 4144912697 | 0; - this.Eh = 1731405415 | 0; - this.El = 4290775857 | 0; - this.Fh = 2394180231 | 0; - this.Fl = 1750603025 | 0; - this.Gh = 3675008525 | 0; - this.Gl = 1694076839 | 0; - this.Hh = 1203062813 | 0; - this.Hl = 3204075428 | 0; - this.outputLen = 48; - } - }; - var sha512 = wrapConstructor2(() => new SHA512()); - var sha512_224 = wrapConstructor2(() => new SHA512_224()); - var sha512_256 = wrapConstructor2(() => new SHA512_256()); - var sha384 = wrapConstructor2(() => new SHA384()); - - // node_modules/@scure/bip32/node_modules/@noble/secp256k1/lib/esm/index.js - init_define_process(); - var nodeCrypto2 = __toESM(require_crypto(), 1); - var _0n2 = BigInt(0); - var _1n2 = BigInt(1); - var _2n2 = BigInt(2); - var _3n2 = BigInt(3); - var _8n2 = BigInt(8); - var CURVE2 = Object.freeze({ - a: _0n2, - b: BigInt(7), - P: BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"), - n: BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"), - h: _1n2, - Gx: BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"), - Gy: BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"), - beta: BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee") - }); - function weistrass(x) { - const { a, b } = CURVE2; - const x2 = mod2(x * x); - const x3 = mod2(x2 * x); - return mod2(x3 + a * x + b); - } - var USE_ENDOMORPHISM2 = CURVE2.a === _0n2; - var ShaError2 = class extends Error { - constructor(message) { - super(message); - } - }; - var JacobianPoint2 = class { - constructor(x, y, z) { - this.x = x; - this.y = y; - this.z = z; - } - static fromAffine(p) { - if (!(p instanceof Point2)) { - throw new TypeError("JacobianPoint#fromAffine: expected Point"); - } - return new JacobianPoint2(p.x, p.y, _1n2); - } - static toAffineBatch(points) { - const toInv = invertBatch2(points.map((p) => p.z)); - return points.map((p, i) => p.toAffine(toInv[i])); - } - static normalizeZ(points) { - return JacobianPoint2.toAffineBatch(points).map(JacobianPoint2.fromAffine); - } - equals(other) { - if (!(other instanceof JacobianPoint2)) - throw new TypeError("JacobianPoint expected"); - const { x: X1, y: Y1, z: Z1 } = this; - const { x: X2, y: Y2, z: Z2 } = other; - const Z1Z1 = mod2(Z1 * Z1); - const Z2Z2 = mod2(Z2 * Z2); - const U1 = mod2(X1 * Z2Z2); - const U2 = mod2(X2 * Z1Z1); - const S1 = mod2(mod2(Y1 * Z2) * Z2Z2); - const S2 = mod2(mod2(Y2 * Z1) * Z1Z1); - return U1 === U2 && S1 === S2; - } - negate() { - return new JacobianPoint2(this.x, mod2(-this.y), this.z); - } - double() { - const { x: X1, y: Y1, z: Z1 } = this; - const A = mod2(X1 * X1); - const B = mod2(Y1 * Y1); - const C = mod2(B * B); - const x1b = X1 + B; - const D = mod2(_2n2 * (mod2(x1b * x1b) - A - C)); - const E = mod2(_3n2 * A); - const F = mod2(E * E); - const X3 = mod2(F - _2n2 * D); - const Y3 = mod2(E * (D - X3) - _8n2 * C); - const Z3 = mod2(_2n2 * Y1 * Z1); - return new JacobianPoint2(X3, Y3, Z3); - } - add(other) { - if (!(other instanceof JacobianPoint2)) - throw new TypeError("JacobianPoint expected"); - const { x: X1, y: Y1, z: Z1 } = this; - const { x: X2, y: Y2, z: Z2 } = other; - if (X2 === _0n2 || Y2 === _0n2) - return this; - if (X1 === _0n2 || Y1 === _0n2) - return other; - const Z1Z1 = mod2(Z1 * Z1); - const Z2Z2 = mod2(Z2 * Z2); - const U1 = mod2(X1 * Z2Z2); - const U2 = mod2(X2 * Z1Z1); - const S1 = mod2(mod2(Y1 * Z2) * Z2Z2); - const S2 = mod2(mod2(Y2 * Z1) * Z1Z1); - const H = mod2(U2 - U1); - const r = mod2(S2 - S1); - if (H === _0n2) { - if (r === _0n2) { - return this.double(); - } else { - return JacobianPoint2.ZERO; - } - } - const HH = mod2(H * H); - const HHH = mod2(H * HH); - const V = mod2(U1 * HH); - const X3 = mod2(r * r - HHH - _2n2 * V); - const Y3 = mod2(r * (V - X3) - S1 * HHH); - const Z3 = mod2(Z1 * Z2 * H); - return new JacobianPoint2(X3, Y3, Z3); - } - subtract(other) { - return this.add(other.negate()); - } - multiplyUnsafe(scalar) { - const P0 = JacobianPoint2.ZERO; - if (typeof scalar === "bigint" && scalar === _0n2) - return P0; - let n = normalizeScalar2(scalar); - if (n === _1n2) - return this; - if (!USE_ENDOMORPHISM2) { - let p = P0; - let d2 = this; - while (n > _0n2) { - if (n & _1n2) - p = p.add(d2); - d2 = d2.double(); - n >>= _1n2; - } - return p; - } - let { k1neg, k1, k2neg, k2 } = splitScalarEndo(n); - let k1p = P0; - let k2p = P0; - let d = this; - while (k1 > _0n2 || k2 > _0n2) { - if (k1 & _1n2) - k1p = k1p.add(d); - if (k2 & _1n2) - k2p = k2p.add(d); - d = d.double(); - k1 >>= _1n2; - k2 >>= _1n2; - } - if (k1neg) - k1p = k1p.negate(); - if (k2neg) - k2p = k2p.negate(); - k2p = new JacobianPoint2(mod2(k2p.x * CURVE2.beta), k2p.y, k2p.z); - return k1p.add(k2p); - } - precomputeWindow(W) { - const windows = USE_ENDOMORPHISM2 ? 128 / W + 1 : 256 / W + 1; - const points = []; - let p = this; - let base = p; - for (let window = 0; window < windows; window++) { - base = p; - points.push(base); - for (let i = 1; i < 2 ** (W - 1); i++) { - base = base.add(p); - points.push(base); - } - p = base.double(); - } - return points; - } - wNAF(n, affinePoint) { - if (!affinePoint && this.equals(JacobianPoint2.BASE)) - affinePoint = Point2.BASE; - const W = affinePoint && affinePoint._WINDOW_SIZE || 1; - if (256 % W) { - throw new Error("Point#wNAF: Invalid precomputation window, must be power of 2"); - } - let precomputes = affinePoint && pointPrecomputes2.get(affinePoint); - if (!precomputes) { - precomputes = this.precomputeWindow(W); - if (affinePoint && W !== 1) { - precomputes = JacobianPoint2.normalizeZ(precomputes); - pointPrecomputes2.set(affinePoint, precomputes); - } - } - let p = JacobianPoint2.ZERO; - let f2 = JacobianPoint2.ZERO; - const windows = 1 + (USE_ENDOMORPHISM2 ? 128 / W : 256 / W); - const windowSize = 2 ** (W - 1); - const mask = BigInt(2 ** W - 1); - const maxNumber = 2 ** W; - const shiftBy = BigInt(W); - for (let window = 0; window < windows; window++) { - const offset = window * windowSize; - let wbits = Number(n & mask); - n >>= shiftBy; - if (wbits > windowSize) { - wbits -= maxNumber; - n += _1n2; - } - if (wbits === 0) { - let pr = precomputes[offset]; - if (window % 2) - pr = pr.negate(); - f2 = f2.add(pr); - } else { - let cached = precomputes[offset + Math.abs(wbits) - 1]; - if (wbits < 0) - cached = cached.negate(); - p = p.add(cached); - } - } - return { p, f: f2 }; - } - multiply(scalar, affinePoint) { - let n = normalizeScalar2(scalar); - let point; - let fake; - if (USE_ENDOMORPHISM2) { - const { k1neg, k1, k2neg, k2 } = splitScalarEndo(n); - let { p: k1p, f: f1p } = this.wNAF(k1, affinePoint); - let { p: k2p, f: f2p } = this.wNAF(k2, affinePoint); - if (k1neg) - k1p = k1p.negate(); - if (k2neg) - k2p = k2p.negate(); - k2p = new JacobianPoint2(mod2(k2p.x * CURVE2.beta), k2p.y, k2p.z); - point = k1p.add(k2p); - fake = f1p.add(f2p); - } else { - const { p, f: f2 } = this.wNAF(n, affinePoint); - point = p; - fake = f2; - } - return JacobianPoint2.normalizeZ([point, fake])[0]; - } - toAffine(invZ = invert2(this.z)) { - const { x, y, z } = this; - const iz1 = invZ; - const iz2 = mod2(iz1 * iz1); - const iz3 = mod2(iz2 * iz1); - const ax = mod2(x * iz2); - const ay = mod2(y * iz3); - const zz = mod2(z * iz1); - if (zz !== _1n2) - throw new Error("invZ was invalid"); - return new Point2(ax, ay); - } - }; - JacobianPoint2.BASE = new JacobianPoint2(CURVE2.Gx, CURVE2.Gy, _1n2); - JacobianPoint2.ZERO = new JacobianPoint2(_0n2, _1n2, _0n2); - var pointPrecomputes2 = /* @__PURE__ */ new WeakMap(); - var Point2 = class { - constructor(x, y) { - this.x = x; - this.y = y; - } - _setWindowSize(windowSize) { - this._WINDOW_SIZE = windowSize; - pointPrecomputes2.delete(this); - } - hasEvenY() { - return this.y % _2n2 === _0n2; - } - static fromCompressedHex(bytes2) { - const isShort = bytes2.length === 32; - const x = bytesToNumber2(isShort ? bytes2 : bytes2.subarray(1)); - if (!isValidFieldElement2(x)) - throw new Error("Point is not on curve"); - const y2 = weistrass(x); - let y = sqrtMod2(y2); - const isYOdd = (y & _1n2) === _1n2; - if (isShort) { - if (isYOdd) - y = mod2(-y); - } else { - const isFirstByteOdd = (bytes2[0] & 1) === 1; - if (isFirstByteOdd !== isYOdd) - y = mod2(-y); - } - const point = new Point2(x, y); - point.assertValidity(); - return point; - } - static fromUncompressedHex(bytes2) { - const x = bytesToNumber2(bytes2.subarray(1, 33)); - const y = bytesToNumber2(bytes2.subarray(33, 65)); - const point = new Point2(x, y); - point.assertValidity(); - return point; - } - static fromHex(hex2) { - const bytes2 = ensureBytes2(hex2); - const len = bytes2.length; - const header = bytes2[0]; - if (len === 32 || len === 33 && (header === 2 || header === 3)) { - return this.fromCompressedHex(bytes2); - } - if (len === 65 && header === 4) - return this.fromUncompressedHex(bytes2); - throw new Error(`Point.fromHex: received invalid point. Expected 32-33 compressed bytes or 65 uncompressed bytes, not ${len}`); - } - static fromPrivateKey(privateKey) { - return Point2.BASE.multiply(normalizePrivateKey2(privateKey)); - } - static fromSignature(msgHash, signature, recovery) { - msgHash = ensureBytes2(msgHash); - const h = truncateHash2(msgHash); - const { r, s } = normalizeSignature2(signature); - if (recovery !== 0 && recovery !== 1) { - throw new Error("Cannot recover signature: invalid recovery bit"); - } - const prefix = recovery & 1 ? "03" : "02"; - const R = Point2.fromHex(prefix + numTo32bStr2(r)); - const { n } = CURVE2; - const rinv = invert2(r, n); - const u1 = mod2(-h * rinv, n); - const u2 = mod2(s * rinv, n); - const Q = Point2.BASE.multiplyAndAddUnsafe(R, u1, u2); - if (!Q) - throw new Error("Cannot recover signature: point at infinify"); - Q.assertValidity(); - return Q; - } - toRawBytes(isCompressed = false) { - return hexToBytes3(this.toHex(isCompressed)); - } - toHex(isCompressed = false) { - const x = numTo32bStr2(this.x); - if (isCompressed) { - const prefix = this.hasEvenY() ? "02" : "03"; - return `${prefix}${x}`; - } else { - return `04${x}${numTo32bStr2(this.y)}`; - } - } - toHexX() { - return this.toHex(true).slice(2); - } - toRawX() { - return this.toRawBytes(true).slice(1); - } - assertValidity() { - const msg = "Point is not on elliptic curve"; - const { x, y } = this; - if (!isValidFieldElement2(x) || !isValidFieldElement2(y)) - throw new Error(msg); - const left = mod2(y * y); - const right = weistrass(x); - if (mod2(left - right) !== _0n2) - throw new Error(msg); - } - equals(other) { - return this.x === other.x && this.y === other.y; - } - negate() { - return new Point2(this.x, mod2(-this.y)); - } - double() { - return JacobianPoint2.fromAffine(this).double().toAffine(); - } - add(other) { - return JacobianPoint2.fromAffine(this).add(JacobianPoint2.fromAffine(other)).toAffine(); - } - subtract(other) { - return this.add(other.negate()); - } - multiply(scalar) { - return JacobianPoint2.fromAffine(this).multiply(scalar, this).toAffine(); - } - multiplyAndAddUnsafe(Q, a, b) { - const P = JacobianPoint2.fromAffine(this); - const aP = a === _0n2 || a === _1n2 || this !== Point2.BASE ? P.multiplyUnsafe(a) : P.multiply(a); - const bQ = JacobianPoint2.fromAffine(Q).multiplyUnsafe(b); - const sum = aP.add(bQ); - return sum.equals(JacobianPoint2.ZERO) ? void 0 : sum.toAffine(); - } - }; - Point2.BASE = new Point2(CURVE2.Gx, CURVE2.Gy); - Point2.ZERO = new Point2(_0n2, _0n2); - function sliceDER2(s) { - return Number.parseInt(s[0], 16) >= 8 ? "00" + s : s; - } - function parseDERInt2(data) { - if (data.length < 2 || data[0] !== 2) { - throw new Error(`Invalid signature integer tag: ${bytesToHex3(data)}`); - } - const len = data[1]; - const res = data.subarray(2, len + 2); - if (!len || res.length !== len) { - throw new Error(`Invalid signature integer: wrong length`); - } - if (res[0] === 0 && res[1] <= 127) { - throw new Error("Invalid signature integer: trailing length"); - } - return { data: bytesToNumber2(res), left: data.subarray(len + 2) }; - } - function parseDERSignature2(data) { - if (data.length < 2 || data[0] != 48) { - throw new Error(`Invalid signature tag: ${bytesToHex3(data)}`); - } - if (data[1] !== data.length - 2) { - throw new Error("Invalid signature: incorrect length"); - } - const { data: r, left: sBytes } = parseDERInt2(data.subarray(2)); - const { data: s, left: rBytesLeft } = parseDERInt2(sBytes); - if (rBytesLeft.length) { - throw new Error(`Invalid signature: left bytes after parsing: ${bytesToHex3(rBytesLeft)}`); - } - return { r, s }; - } - var Signature2 = class { - constructor(r, s) { - this.r = r; - this.s = s; - this.assertValidity(); - } - static fromCompact(hex2) { - const arr = hex2 instanceof Uint8Array; - const name = "Signature.fromCompact"; - if (typeof hex2 !== "string" && !arr) - throw new TypeError(`${name}: Expected string or Uint8Array`); - const str = arr ? bytesToHex3(hex2) : hex2; - if (str.length !== 128) - throw new Error(`${name}: Expected 64-byte hex`); - return new Signature2(hexToNumber2(str.slice(0, 64)), hexToNumber2(str.slice(64, 128))); - } - static fromDER(hex2) { - const arr = hex2 instanceof Uint8Array; - if (typeof hex2 !== "string" && !arr) - throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`); - const { r, s } = parseDERSignature2(arr ? hex2 : hexToBytes3(hex2)); - return new Signature2(r, s); - } - static fromHex(hex2) { - return this.fromDER(hex2); - } - assertValidity() { - const { r, s } = this; - if (!isWithinCurveOrder2(r)) - throw new Error("Invalid Signature: r must be 0 < r < n"); - if (!isWithinCurveOrder2(s)) - throw new Error("Invalid Signature: s must be 0 < s < n"); - } - hasHighS() { - const HALF = CURVE2.n >> _1n2; - return this.s > HALF; - } - normalizeS() { - return this.hasHighS() ? new Signature2(this.r, CURVE2.n - this.s) : this; - } - toDERRawBytes(isCompressed = false) { - return hexToBytes3(this.toDERHex(isCompressed)); - } - toDERHex(isCompressed = false) { - const sHex = sliceDER2(numberToHexUnpadded2(this.s)); - if (isCompressed) - return sHex; - const rHex = sliceDER2(numberToHexUnpadded2(this.r)); - const rLen = numberToHexUnpadded2(rHex.length / 2); - const sLen = numberToHexUnpadded2(sHex.length / 2); - const length = numberToHexUnpadded2(rHex.length / 2 + sHex.length / 2 + 4); - return `30${length}02${rLen}${rHex}02${sLen}${sHex}`; - } - toRawBytes() { - return this.toDERRawBytes(); - } - toHex() { - return this.toDERHex(); - } - toCompactRawBytes() { - return hexToBytes3(this.toCompactHex()); - } - toCompactHex() { - return numTo32bStr2(this.r) + numTo32bStr2(this.s); - } - }; - function concatBytes3(...arrays) { - if (!arrays.every((b) => b instanceof Uint8Array)) - throw new Error("Uint8Array list expected"); - if (arrays.length === 1) - return arrays[0]; - const length = arrays.reduce((a, arr) => a + arr.length, 0); - const result = new Uint8Array(length); - for (let i = 0, pad = 0; i < arrays.length; i++) { - const arr = arrays[i]; - result.set(arr, pad); - pad += arr.length; - } - return result; - } - var hexes4 = Array.from({ length: 256 }, (v, i) => i.toString(16).padStart(2, "0")); - function bytesToHex3(uint8a) { - if (!(uint8a instanceof Uint8Array)) - throw new Error("Expected Uint8Array"); - let hex2 = ""; - for (let i = 0; i < uint8a.length; i++) { - hex2 += hexes4[uint8a[i]]; - } - return hex2; - } - var POW_2_2562 = BigInt("0x10000000000000000000000000000000000000000000000000000000000000000"); - function numTo32bStr2(num) { - if (typeof num !== "bigint") - throw new Error("Expected bigint"); - if (!(_0n2 <= num && num < POW_2_2562)) - throw new Error("Expected number < 2^256"); - return num.toString(16).padStart(64, "0"); - } - function numTo32b2(num) { - const b = hexToBytes3(numTo32bStr2(num)); - if (b.length !== 32) - throw new Error("Error: expected 32 bytes"); - return b; - } - function numberToHexUnpadded2(num) { - const hex2 = num.toString(16); - return hex2.length & 1 ? `0${hex2}` : hex2; - } - function hexToNumber2(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToNumber: expected string, got " + typeof hex2); - } - return BigInt(`0x${hex2}`); - } - function hexToBytes3(hex2) { - if (typeof hex2 !== "string") { - throw new TypeError("hexToBytes: expected string, got " + typeof hex2); - } - if (hex2.length % 2) - throw new Error("hexToBytes: received invalid unpadded hex" + hex2.length); - const array = new Uint8Array(hex2.length / 2); - for (let i = 0; i < array.length; i++) { - const j = i * 2; - const hexByte = hex2.slice(j, j + 2); - const byte = Number.parseInt(hexByte, 16); - if (Number.isNaN(byte) || byte < 0) - throw new Error("Invalid byte sequence"); - array[i] = byte; - } - return array; - } - function bytesToNumber2(bytes2) { - return hexToNumber2(bytesToHex3(bytes2)); - } - function ensureBytes2(hex2) { - return hex2 instanceof Uint8Array ? Uint8Array.from(hex2) : hexToBytes3(hex2); - } - function normalizeScalar2(num) { - if (typeof num === "number" && Number.isSafeInteger(num) && num > 0) - return BigInt(num); - if (typeof num === "bigint" && isWithinCurveOrder2(num)) - return num; - throw new TypeError("Expected valid private scalar: 0 < scalar < curve.n"); - } - function mod2(a, b = CURVE2.P) { - const result = a % b; - return result >= _0n2 ? result : b + result; - } - function pow22(x, power) { - const { P } = CURVE2; - let res = x; - while (power-- > _0n2) { - res *= res; - res %= P; - } - return res; - } - function sqrtMod2(x) { - const { P } = CURVE2; - const _6n = BigInt(6); - const _11n = BigInt(11); - const _22n = BigInt(22); - const _23n = BigInt(23); - const _44n = BigInt(44); - const _88n = BigInt(88); - const b2 = x * x * x % P; - const b3 = b2 * b2 * x % P; - const b6 = pow22(b3, _3n2) * b3 % P; - const b9 = pow22(b6, _3n2) * b3 % P; - const b11 = pow22(b9, _2n2) * b2 % P; - const b22 = pow22(b11, _11n) * b11 % P; - const b44 = pow22(b22, _22n) * b22 % P; - const b88 = pow22(b44, _44n) * b44 % P; - const b176 = pow22(b88, _88n) * b88 % P; - const b220 = pow22(b176, _44n) * b44 % P; - const b223 = pow22(b220, _3n2) * b3 % P; - const t1 = pow22(b223, _23n) * b22 % P; - const t2 = pow22(t1, _6n) * b2 % P; - return pow22(t2, _2n2); - } - function invert2(number2, modulo = CURVE2.P) { - if (number2 === _0n2 || modulo <= _0n2) { - throw new Error(`invert: expected positive integers, got n=${number2} mod=${modulo}`); - } - let a = mod2(number2, modulo); - let b = modulo; - let x = _0n2, y = _1n2, u = _1n2, v = _0n2; - while (a !== _0n2) { - const q = b / a; - const r = b % a; - const m = x - u * q; - const n = y - v * q; - b = a, a = r, x = u, y = v, u = m, v = n; - } - const gcd2 = b; - if (gcd2 !== _1n2) - throw new Error("invert: does not exist"); - return mod2(x, modulo); - } - function invertBatch2(nums, p = CURVE2.P) { - const scratch = new Array(nums.length); - const lastMultiplied = nums.reduce((acc, num, i) => { - if (num === _0n2) - return acc; - scratch[i] = acc; - return mod2(acc * num, p); - }, _1n2); - const inverted = invert2(lastMultiplied, p); - nums.reduceRight((acc, num, i) => { - if (num === _0n2) - return acc; - scratch[i] = mod2(acc * scratch[i], p); - return mod2(acc * num, p); - }, inverted); - return scratch; - } - var divNearest2 = (a, b) => (a + b / _2n2) / b; - var ENDO = { - a1: BigInt("0x3086d221a7d46bcde86c90e49284eb15"), - b1: -_1n2 * BigInt("0xe4437ed6010e88286f547fa90abfe4c3"), - a2: BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"), - b2: BigInt("0x3086d221a7d46bcde86c90e49284eb15"), - POW_2_128: BigInt("0x100000000000000000000000000000000") - }; - function splitScalarEndo(k) { - const { n } = CURVE2; - const { a1, b1, a2, b2, POW_2_128 } = ENDO; - const c1 = divNearest2(b2 * k, n); - const c2 = divNearest2(-b1 * k, n); - let k1 = mod2(k - c1 * a1 - c2 * a2, n); - let k2 = mod2(-c1 * b1 - c2 * b2, n); - const k1neg = k1 > POW_2_128; - const k2neg = k2 > POW_2_128; - if (k1neg) - k1 = n - k1; - if (k2neg) - k2 = n - k2; - if (k1 > POW_2_128 || k2 > POW_2_128) { - throw new Error("splitScalarEndo: Endomorphism failed, k=" + k); - } - return { k1neg, k1, k2neg, k2 }; - } - function truncateHash2(hash2) { - const { n } = CURVE2; - const byteLength = hash2.length; - const delta = byteLength * 8 - 256; - let h = bytesToNumber2(hash2); - if (delta > 0) - h = h >> BigInt(delta); - if (h >= n) - h -= n; - return h; - } - var _sha256Sync2; - var _hmacSha256Sync2; - var HmacDrbg = class { - constructor() { - this.v = new Uint8Array(32).fill(1); - this.k = new Uint8Array(32).fill(0); - this.counter = 0; - } - hmac(...values) { - return utils2.hmacSha256(this.k, ...values); - } - hmacSync(...values) { - return _hmacSha256Sync2(this.k, ...values); - } - checkSync() { - if (typeof _hmacSha256Sync2 !== "function") - throw new ShaError2("hmacSha256Sync needs to be set"); - } - incr() { - if (this.counter >= 1e3) - throw new Error("Tried 1,000 k values for sign(), all were invalid"); - this.counter += 1; - } - async reseed(seed = new Uint8Array()) { - this.k = await this.hmac(this.v, Uint8Array.from([0]), seed); - this.v = await this.hmac(this.v); - if (seed.length === 0) - return; - this.k = await this.hmac(this.v, Uint8Array.from([1]), seed); - this.v = await this.hmac(this.v); - } - reseedSync(seed = new Uint8Array()) { - this.checkSync(); - this.k = this.hmacSync(this.v, Uint8Array.from([0]), seed); - this.v = this.hmacSync(this.v); - if (seed.length === 0) - return; - this.k = this.hmacSync(this.v, Uint8Array.from([1]), seed); - this.v = this.hmacSync(this.v); - } - async generate() { - this.incr(); - this.v = await this.hmac(this.v); - return this.v; - } - generateSync() { - this.checkSync(); - this.incr(); - this.v = this.hmacSync(this.v); - return this.v; - } - }; - function isWithinCurveOrder2(num) { - return _0n2 < num && num < CURVE2.n; - } - function isValidFieldElement2(num) { - return _0n2 < num && num < CURVE2.P; - } - function kmdToSig(kBytes, m, d) { - const k = bytesToNumber2(kBytes); - if (!isWithinCurveOrder2(k)) - return; - const { n } = CURVE2; - const q = Point2.BASE.multiply(k); - const r = mod2(q.x, n); - if (r === _0n2) - return; - const s = mod2(invert2(k, n) * mod2(m + d * r, n), n); - if (s === _0n2) - return; - const sig = new Signature2(r, s); - const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n2); - return { sig, recovery }; - } - function normalizePrivateKey2(key) { - let num; - if (typeof key === "bigint") { - num = key; - } else if (typeof key === "number" && Number.isSafeInteger(key) && key > 0) { - num = BigInt(key); - } else if (typeof key === "string") { - if (key.length !== 64) - throw new Error("Expected 32 bytes of private key"); - num = hexToNumber2(key); - } else if (key instanceof Uint8Array) { - if (key.length !== 32) - throw new Error("Expected 32 bytes of private key"); - num = bytesToNumber2(key); - } else { - throw new TypeError("Expected valid private key"); - } - if (!isWithinCurveOrder2(num)) - throw new Error("Expected private key: 0 < key < n"); - return num; - } - function normalizePublicKey2(publicKey) { - if (publicKey instanceof Point2) { - publicKey.assertValidity(); - return publicKey; - } else { - return Point2.fromHex(publicKey); - } - } - function normalizeSignature2(signature) { - if (signature instanceof Signature2) { - signature.assertValidity(); - return signature; - } - try { - return Signature2.fromDER(signature); - } catch (error) { - return Signature2.fromCompact(signature); - } - } - function getPublicKey2(privateKey, isCompressed = false) { - return Point2.fromPrivateKey(privateKey).toRawBytes(isCompressed); - } - function bits2int(bytes2) { - const slice = bytes2.length > 32 ? bytes2.slice(0, 32) : bytes2; - return bytesToNumber2(slice); - } - function bits2octets(bytes2) { - const z1 = bits2int(bytes2); - const z2 = mod2(z1, CURVE2.n); - return int2octets(z2 < _0n2 ? z1 : z2); - } - function int2octets(num) { - return numTo32b2(num); - } - function initSigArgs(msgHash, privateKey, extraEntropy) { - if (msgHash == null) - throw new Error(`sign: expected valid message hash, not "${msgHash}"`); - const h1 = ensureBytes2(msgHash); - const d = normalizePrivateKey2(privateKey); - const seedArgs = [int2octets(d), bits2octets(h1)]; - if (extraEntropy != null) { - if (extraEntropy === true) - extraEntropy = utils2.randomBytes(32); - const e = ensureBytes2(extraEntropy); - if (e.length !== 32) - throw new Error("sign: Expected 32 bytes of extra data"); - seedArgs.push(e); - } - const seed = concatBytes3(...seedArgs); - const m = bits2int(h1); - return { seed, m, d }; - } - function finalizeSig(recSig, opts) { - let { sig, recovery } = recSig; - const { canonical, der, recovered } = Object.assign({ canonical: true, der: true }, opts); - if (canonical && sig.hasHighS()) { - sig = sig.normalizeS(); - recovery ^= 1; - } - const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes(); - return recovered ? [hashed, recovery] : hashed; - } - function signSync(msgHash, privKey, opts = {}) { - const { seed, m, d } = initSigArgs(msgHash, privKey, opts.extraEntropy); - let sig; - const drbg = new HmacDrbg(); - drbg.reseedSync(seed); - while (!(sig = kmdToSig(drbg.generateSync(), m, d))) - drbg.reseedSync(); - return finalizeSig(sig, opts); - } - var vopts = { strict: true }; - function verify(signature, msgHash, publicKey, opts = vopts) { - let sig; - try { - sig = normalizeSignature2(signature); - msgHash = ensureBytes2(msgHash); - } catch (error) { - return false; - } - const { r, s } = sig; - if (opts.strict && sig.hasHighS()) - return false; - const h = truncateHash2(msgHash); - let P; - try { - P = normalizePublicKey2(publicKey); - } catch (error) { - return false; - } - const { n } = CURVE2; - const sinv = invert2(s, n); - const u1 = mod2(h * sinv, n); - const u2 = mod2(r * sinv, n); - const R = Point2.BASE.multiplyAndAddUnsafe(P, u1, u2); - if (!R) - return false; - const v = mod2(R.x, n); - return v === r; - } - Point2.BASE._setWindowSize(8); - var crypto5 = { - node: nodeCrypto2, - web: typeof self === "object" && "crypto" in self ? self.crypto : void 0 - }; - var TAGGED_HASH_PREFIXES2 = {}; - var utils2 = { - bytesToHex: bytesToHex3, - hexToBytes: hexToBytes3, - concatBytes: concatBytes3, - mod: mod2, - invert: invert2, - isValidPrivateKey(privateKey) { - try { - normalizePrivateKey2(privateKey); - return true; - } catch (error) { - return false; - } - }, - _bigintTo32Bytes: numTo32b2, - _normalizePrivateKey: normalizePrivateKey2, - hashToPrivateKey: (hash2) => { - hash2 = ensureBytes2(hash2); - if (hash2.length < 40 || hash2.length > 1024) - throw new Error("Expected 40-1024 bytes of private key as per FIPS 186"); - const num = mod2(bytesToNumber2(hash2), CURVE2.n - _1n2) + _1n2; - return numTo32b2(num); - }, - randomBytes: (bytesLength = 32) => { - if (crypto5.web) { - return crypto5.web.getRandomValues(new Uint8Array(bytesLength)); - } else if (crypto5.node) { - const { randomBytes: randomBytes2 } = crypto5.node; - return Uint8Array.from(randomBytes2(bytesLength)); - } else { - throw new Error("The environment doesn't have randomBytes function"); - } - }, - randomPrivateKey: () => { - return utils2.hashToPrivateKey(utils2.randomBytes(40)); - }, - sha256: async (...messages) => { - if (crypto5.web) { - const buffer = await crypto5.web.subtle.digest("SHA-256", concatBytes3(...messages)); - return new Uint8Array(buffer); - } else if (crypto5.node) { - const { createHash } = crypto5.node; - const hash2 = createHash("sha256"); - messages.forEach((m) => hash2.update(m)); - return Uint8Array.from(hash2.digest()); - } else { - throw new Error("The environment doesn't have sha256 function"); - } - }, - hmacSha256: async (key, ...messages) => { - if (crypto5.web) { - const ckey = await crypto5.web.subtle.importKey("raw", key, { name: "HMAC", hash: { name: "SHA-256" } }, false, ["sign"]); - const message = concatBytes3(...messages); - const buffer = await crypto5.web.subtle.sign("HMAC", ckey, message); - return new Uint8Array(buffer); - } else if (crypto5.node) { - const { createHmac } = crypto5.node; - const hash2 = createHmac("sha256", key); - messages.forEach((m) => hash2.update(m)); - return Uint8Array.from(hash2.digest()); - } else { - throw new Error("The environment doesn't have hmac-sha256 function"); - } - }, - sha256Sync: void 0, - hmacSha256Sync: void 0, - taggedHash: async (tag, ...messages) => { - let tagP = TAGGED_HASH_PREFIXES2[tag]; - if (tagP === void 0) { - const tagH = await utils2.sha256(Uint8Array.from(tag, (c) => c.charCodeAt(0))); - tagP = concatBytes3(tagH, tagH); - TAGGED_HASH_PREFIXES2[tag] = tagP; - } - return utils2.sha256(tagP, ...messages); - }, - taggedHashSync: (tag, ...messages) => { - if (typeof _sha256Sync2 !== "function") - throw new ShaError2("sha256Sync is undefined, you need to set it"); - let tagP = TAGGED_HASH_PREFIXES2[tag]; - if (tagP === void 0) { - const tagH = _sha256Sync2(Uint8Array.from(tag, (c) => c.charCodeAt(0))); - tagP = concatBytes3(tagH, tagH); - TAGGED_HASH_PREFIXES2[tag] = tagP; - } - return _sha256Sync2(tagP, ...messages); - }, - precompute(windowSize = 8, point = Point2.BASE) { - const cached = point === Point2.BASE ? point : new Point2(point.x, point.y); - cached._setWindowSize(windowSize); - cached.multiply(_3n2); - return cached; - } - }; - Object.defineProperties(utils2, { - sha256Sync: { - configurable: false, - get() { - return _sha256Sync2; - }, - set(val) { - if (!_sha256Sync2) - _sha256Sync2 = val; - } - }, - hmacSha256Sync: { - configurable: false, - get() { - return _hmacSha256Sync2; - }, - set(val) { - if (!_hmacSha256Sync2) - _hmacSha256Sync2 = val; - } - } - }); - - // node_modules/@scure/bip32/lib/esm/index.js - utils2.hmacSha256Sync = (key, ...msgs) => hmac(sha2562, key, utils2.concatBytes(...msgs)); - var base58check2 = base58check(sha2562); - function bytesToNumber3(bytes2) { - return BigInt(`0x${bytesToHex2(bytes2)}`); - } - function numberToBytes(num) { - return hexToBytes2(num.toString(16).padStart(64, "0")); - } - var MASTER_SECRET = utf8ToBytes2("Bitcoin seed"); - var BITCOIN_VERSIONS = { private: 76066276, public: 76067358 }; - var HARDENED_OFFSET = 2147483648; - var hash160 = (data) => ripemd160(sha2562(data)); - var fromU32 = (data) => createView2(data).getUint32(0, false); - var toU32 = (n) => { - if (!Number.isSafeInteger(n) || n < 0 || n > 2 ** 32 - 1) { - throw new Error(`Invalid number=${n}. Should be from 0 to 2 ** 32 - 1`); - } - const buf = new Uint8Array(4); - createView2(buf).setUint32(0, n, false); - return buf; - }; - var HDKey = class { - constructor(opt) { - this.depth = 0; - this.index = 0; - this.chainCode = null; - this.parentFingerprint = 0; - if (!opt || typeof opt !== "object") { - throw new Error("HDKey.constructor must not be called directly"); - } - this.versions = opt.versions || BITCOIN_VERSIONS; - this.depth = opt.depth || 0; - this.chainCode = opt.chainCode; - this.index = opt.index || 0; - this.parentFingerprint = opt.parentFingerprint || 0; - if (!this.depth) { - if (this.parentFingerprint || this.index) { - throw new Error("HDKey: zero depth with non-zero index/parent fingerprint"); - } - } - if (opt.publicKey && opt.privateKey) { - throw new Error("HDKey: publicKey and privateKey at same time."); - } - if (opt.privateKey) { - if (!utils2.isValidPrivateKey(opt.privateKey)) { - throw new Error("Invalid private key"); - } - this.privKey = typeof opt.privateKey === "bigint" ? opt.privateKey : bytesToNumber3(opt.privateKey); - this.privKeyBytes = numberToBytes(this.privKey); - this.pubKey = getPublicKey2(opt.privateKey, true); - } else if (opt.publicKey) { - this.pubKey = Point2.fromHex(opt.publicKey).toRawBytes(true); - } else { - throw new Error("HDKey: no public or private key provided"); - } - this.pubHash = hash160(this.pubKey); - } - get fingerprint() { - if (!this.pubHash) { - throw new Error("No publicKey set!"); - } - return fromU32(this.pubHash); - } - get identifier() { - return this.pubHash; - } - get pubKeyHash() { - return this.pubHash; - } - get privateKey() { - return this.privKeyBytes || null; - } - get publicKey() { - return this.pubKey || null; - } - get privateExtendedKey() { - const priv = this.privateKey; - if (!priv) { - throw new Error("No private key"); - } - return base58check2.encode(this.serialize(this.versions.private, concatBytes2(new Uint8Array([0]), priv))); - } - get publicExtendedKey() { - if (!this.pubKey) { - throw new Error("No public key"); - } - return base58check2.encode(this.serialize(this.versions.public, this.pubKey)); - } - static fromMasterSeed(seed, versions = BITCOIN_VERSIONS) { - bytes(seed); - if (8 * seed.length < 128 || 8 * seed.length > 512) { - throw new Error(`HDKey: wrong seed length=${seed.length}. Should be between 128 and 512 bits; 256 bits is advised)`); - } - const I = hmac(sha512, MASTER_SECRET, seed); - return new HDKey({ - versions, - chainCode: I.slice(32), - privateKey: I.slice(0, 32) - }); - } - static fromExtendedKey(base58key, versions = BITCOIN_VERSIONS) { - const keyBuffer = base58check2.decode(base58key); - const keyView = createView2(keyBuffer); - const version = keyView.getUint32(0, false); - const opt = { - versions, - depth: keyBuffer[4], - parentFingerprint: keyView.getUint32(5, false), - index: keyView.getUint32(9, false), - chainCode: keyBuffer.slice(13, 45) - }; - const key = keyBuffer.slice(45); - const isPriv = key[0] === 0; - if (version !== versions[isPriv ? "private" : "public"]) { - throw new Error("Version mismatch"); - } - if (isPriv) { - return new HDKey({ ...opt, privateKey: key.slice(1) }); - } else { - return new HDKey({ ...opt, publicKey: key }); - } - } - static fromJSON(json) { - return HDKey.fromExtendedKey(json.xpriv); - } - derive(path) { - if (!/^[mM]'?/.test(path)) { - throw new Error('Path must start with "m" or "M"'); - } - if (/^[mM]'?$/.test(path)) { - return this; - } - const parts = path.replace(/^[mM]'?\//, "").split("/"); - let child = this; - for (const c of parts) { - const m = /^(\d+)('?)$/.exec(c); - if (!m || m.length !== 3) { - throw new Error(`Invalid child index: ${c}`); - } - let idx = +m[1]; - if (!Number.isSafeInteger(idx) || idx >= HARDENED_OFFSET) { - throw new Error("Invalid index"); - } - if (m[2] === "'") { - idx += HARDENED_OFFSET; - } - child = child.deriveChild(idx); - } - return child; - } - deriveChild(index) { - if (!this.pubKey || !this.chainCode) { - throw new Error("No publicKey or chainCode set"); - } - let data = toU32(index); - if (index >= HARDENED_OFFSET) { - const priv = this.privateKey; - if (!priv) { - throw new Error("Could not derive hardened child key"); - } - data = concatBytes2(new Uint8Array([0]), priv, data); - } else { - data = concatBytes2(this.pubKey, data); - } - const I = hmac(sha512, this.chainCode, data); - const childTweak = bytesToNumber3(I.slice(0, 32)); - const chainCode = I.slice(32); - if (!utils2.isValidPrivateKey(childTweak)) { - throw new Error("Tweak bigger than curve order"); - } - const opt = { - versions: this.versions, - chainCode, - depth: this.depth + 1, - parentFingerprint: this.fingerprint, - index - }; - try { - if (this.privateKey) { - const added = utils2.mod(this.privKey + childTweak, CURVE2.n); - if (!utils2.isValidPrivateKey(added)) { - throw new Error("The tweak was out of range or the resulted private key is invalid"); - } - opt.privateKey = added; - } else { - const added = Point2.fromHex(this.pubKey).add(Point2.fromPrivateKey(childTweak)); - if (added.equals(Point2.ZERO)) { - throw new Error("The tweak was equal to negative P, which made the result key invalid"); - } - opt.publicKey = added.toRawBytes(true); - } - return new HDKey(opt); - } catch (err) { - return this.deriveChild(index + 1); - } - } - sign(hash2) { - if (!this.privateKey) { - throw new Error("No privateKey set!"); - } - bytes(hash2, 32); - return signSync(hash2, this.privKey, { - canonical: true, - der: false - }); - } - verify(hash2, signature) { - bytes(hash2, 32); - bytes(signature, 64); - if (!this.publicKey) { - throw new Error("No publicKey set!"); - } - let sig; - try { - sig = Signature2.fromCompact(signature); - } catch (error) { - return false; - } - return verify(sig, hash2, this.publicKey); - } - wipePrivateData() { - this.privKey = void 0; - if (this.privKeyBytes) { - this.privKeyBytes.fill(0); - this.privKeyBytes = void 0; - } - return this; - } - toJSON() { - return { - xpriv: this.privateExtendedKey, - xpub: this.publicExtendedKey - }; - } - serialize(version, key) { - if (!this.chainCode) { - throw new Error("No chainCode set"); - } - bytes(key, 33); - return concatBytes2(toU32(version), new Uint8Array([this.depth]), toU32(this.parentFingerprint), toU32(this.index), this.chainCode, key); - } - }; - - // nip06.ts - function privateKeyFromSeedWords(mnemonic, passphrase) { - let root = HDKey.fromMasterSeed((0, import_bip39.mnemonicToSeedSync)(mnemonic, passphrase)); - let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey; - if (!privateKey) - throw new Error("could not derive private key"); - return utils.bytesToHex(privateKey); - } - function generateSeedWords() { - return (0, import_bip39.generateMnemonic)(import_english.wordlist); - } - function validateWords(words) { - return (0, import_bip39.validateMnemonic)(words, import_english.wordlist); - } - - // nip19.ts - var nip19_exports = {}; - __export(nip19_exports, { - decode: () => decode, - naddrEncode: () => naddrEncode, - neventEncode: () => neventEncode, - noteEncode: () => noteEncode, - nprofileEncode: () => nprofileEncode, - npubEncode: () => npubEncode, - nsecEncode: () => nsecEncode - }); - init_define_process(); - var Bech32MaxSize = 5e3; - function decode(nip19) { - let { prefix, words } = bech32.decode(nip19, Bech32MaxSize); - let data = new Uint8Array(bech32.fromWords(words)); - switch (prefix) { - case "nprofile": { - let tlv = parseTLV(data); - if (!tlv[0]?.[0]) - throw new Error("missing TLV 0 for nprofile"); - if (tlv[0][0].length !== 32) - throw new Error("TLV 0 should be 32 bytes"); - return { - type: "nprofile", - data: { - pubkey: utils.bytesToHex(tlv[0][0]), - relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [] - } - }; - } - case "nevent": { - let tlv = parseTLV(data); - if (!tlv[0]?.[0]) - throw new Error("missing TLV 0 for nevent"); - if (tlv[0][0].length !== 32) - throw new Error("TLV 0 should be 32 bytes"); - return { - type: "nevent", - data: { - id: utils.bytesToHex(tlv[0][0]), - relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [] - } - }; - } - case "naddr": { - let tlv = parseTLV(data); - if (!tlv[0]?.[0]) - throw new Error("missing TLV 0 for naddr"); - if (!tlv[2]?.[0]) - throw new Error("missing TLV 2 for naddr"); - if (tlv[2][0].length !== 32) - throw new Error("TLV 2 should be 32 bytes"); - if (!tlv[3]?.[0]) - throw new Error("missing TLV 3 for naddr"); - if (tlv[3][0].length !== 4) - throw new Error("TLV 3 should be 4 bytes"); - return { - type: "naddr", - data: { - identifier: utf8Decoder.decode(tlv[0][0]), - pubkey: utils.bytesToHex(tlv[2][0]), - kind: parseInt(utils.bytesToHex(tlv[3][0]), 16), - relays: tlv[1] ? tlv[1].map((d) => utf8Decoder.decode(d)) : [] - } - }; - } - case "nsec": - case "npub": - case "note": - return { type: prefix, data: utils.bytesToHex(data) }; - default: - throw new Error(`unknown prefix ${prefix}`); - } - } - function parseTLV(data) { - let result = {}; - let rest = data; - while (rest.length > 0) { - let t = rest[0]; - let l = rest[1]; - let v = rest.slice(2, 2 + l); - rest = rest.slice(2 + l); - if (v.length < l) - continue; - result[t] = result[t] || []; - result[t].push(v); - } - return result; - } - function nsecEncode(hex2) { - return encodeBytes("nsec", hex2); - } - function npubEncode(hex2) { - return encodeBytes("npub", hex2); - } - function noteEncode(hex2) { - return encodeBytes("note", hex2); - } - function encodeBytes(prefix, hex2) { - let data = utils.hexToBytes(hex2); - let words = bech32.toWords(data); - return bech32.encode(prefix, words, Bech32MaxSize); - } - function nprofileEncode(profile) { - let data = encodeTLV({ - 0: [utils.hexToBytes(profile.pubkey)], - 1: (profile.relays || []).map((url) => utf8Encoder.encode(url)) - }); - let words = bech32.toWords(data); - return bech32.encode("nprofile", words, Bech32MaxSize); - } - function neventEncode(event) { - let data = encodeTLV({ - 0: [utils.hexToBytes(event.id)], - 1: (event.relays || []).map((url) => utf8Encoder.encode(url)) - }); - let words = bech32.toWords(data); - return bech32.encode("nevent", words, Bech32MaxSize); - } - function naddrEncode(addr) { - let kind = new ArrayBuffer(4); - new DataView(kind).setUint32(0, addr.kind, false); - let data = encodeTLV({ - 0: [utf8Encoder.encode(addr.identifier)], - 1: (addr.relays || []).map((url) => utf8Encoder.encode(url)), - 2: [utils.hexToBytes(addr.pubkey)], - 3: [new Uint8Array(kind)] - }); - let words = bech32.toWords(data); - return bech32.encode("naddr", words, Bech32MaxSize); - } - function encodeTLV(tlv) { - let entries = []; - Object.entries(tlv).forEach(([t, vs]) => { - vs.forEach((v) => { - let entry = new Uint8Array(v.length + 2); - entry.set([parseInt(t)], 0); - entry.set([v.length], 1); - entry.set(v, 2); - entries.push(entry); - }); - }); - return utils.concatBytes(...entries); - } - - // nip26.ts - var nip26_exports = {}; - __export(nip26_exports, { - createDelegation: () => createDelegation, - getDelegator: () => getDelegator - }); - init_define_process(); - function createDelegation(privateKey, parameters) { - let conditions = []; - if ((parameters.kind || -1) >= 0) - conditions.push(`kind=${parameters.kind}`); - if (parameters.until) - conditions.push(`created_at<${parameters.until}`); - if (parameters.since) - conditions.push(`created_at>${parameters.since}`); - let cond = conditions.join("&"); - if (cond === "") - throw new Error("refusing to create a delegation without any conditions"); - let sighash = sha256( - utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`) - ); - let sig = utils.bytesToHex( - schnorr.signSync(sighash, privateKey) - ); - return { - from: getPublicKey(privateKey), - to: parameters.pubkey, - cond, - sig - }; - } - function getDelegator(event) { - let tag = event.tags.find((tag2) => tag2[0] === "delegation" && tag2.length >= 4); - if (!tag) - return null; - let pubkey = tag[1]; - let cond = tag[2]; - let sig = tag[3]; - let conditions = cond.split("&"); - for (let i = 0; i < conditions.length; i++) { - let [key, operator, value] = conditions[i].split(/\b/); - if (key === "kind" && operator === "=" && event.kind === parseInt(value)) - continue; - else if (key === "created_at" && operator === "<" && event.created_at < parseInt(value)) - continue; - else if (key === "created_at" && operator === ">" && event.created_at > parseInt(value)) - continue; - else - return null; - } - let sighash = sha256( - utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`) - ); - if (!schnorr.verifySync(sig, sighash, pubkey)) - return null; - return pubkey; - } - - // nip57.ts - var nip57_exports = {}; - __export(nip57_exports, { - getZapEndpoint: () => getZapEndpoint, - makeZapReceipt: () => makeZapReceipt, - makeZapRequest: () => makeZapRequest, - useFetchImplementation: () => useFetchImplementation2, - validateZapRequest: () => validateZapRequest - }); - init_define_process(); - var _fetch2; - try { - _fetch2 = fetch; - } catch { - } - function useFetchImplementation2(fetchImplementation) { - _fetch2 = fetchImplementation; - } - async function getZapEndpoint(metadata) { - try { - let lnurl = ""; - let { lud06, lud16 } = JSON.parse(metadata.content); - if (lud06) { - let { words } = bech32.decode(lud06, 1e3); - let data = bech32.fromWords(words); - lnurl = utf8Decoder.decode(data); - } else if (lud16) { - let [name, domain] = lud16.split("@"); - lnurl = `https://${domain}/.well-known/lnurlp/${name}`; - } else { - return null; - } - let res = await _fetch2(lnurl); - let body = await res.json(); - if (body.allowsNostr && body.nostrPubkey) { - return body.callback; - } - } catch (err) { - } - return null; - } - function makeZapRequest({ - profile, - event, - amount, - relays, - comment = "" - }) { - if (!amount) - throw new Error("amount not given"); - if (!profile) - throw new Error("profile not given"); - let zr = { - kind: 9734, - created_at: Math.round(Date.now() / 1e3), - content: comment, - tags: [ - ["p", profile], - ["amount", amount.toString()], - ["relays", ...relays] - ] - }; - if (event) { - zr.tags.push(["e", event]); - } - return zr; - } - function validateZapRequest(zapRequestString) { - let zapRequest; - try { - zapRequest = JSON.parse(zapRequestString); - } catch (err) { - return "Invalid zap request JSON."; - } - if (!validateEvent(zapRequest)) - return "Zap request is not a valid Nostr event."; - if (!verifySignature(zapRequest)) - return "Invalid signature on zap request."; - let p = zapRequest.tags.find(([t, v]) => t === "p" && v); - if (!p) - return "Zap request doesn't have a 'p' tag."; - if (!p[1].match(/^[a-f0-9]{64}$/)) - return "Zap request 'p' tag is not valid hex."; - let e = zapRequest.tags.find(([t, v]) => t === "e" && v); - if (e && !e[1].match(/^[a-f0-9]{64}$/)) - return "Zap request 'e' tag is not valid hex."; - let relays = zapRequest.tags.find(([t, v]) => t === "relays" && v); - if (!relays) - return "Zap request doesn't have a 'relays' tag."; - return null; - } - function makeZapReceipt({ - zapRequest, - preimage, - bolt11, - paidAt - }) { - let zr = JSON.parse(zapRequest); - let tagsFromZapRequest = zr.tags.filter( - ([t]) => t === "e" || t === "p" || t === "a" - ); - let zap = { - kind: 9735, - created_at: Math.round(paidAt.getTime() / 1e3), - content: "", - tags: [ - ...tagsFromZapRequest, - ["bolt11", bolt11], - ["description", zapRequest] - ] - }; - if (preimage) { - zap.tags.push(["preimage", preimage]); - } - return zap; - } - - // node_modules/@noble/hashes/esm/hmac.js - init_define_process(); - var HMAC2 = class extends Hash { - constructor(hash2, _key) { - super(); - this.finished = false; - this.destroyed = false; - assertHash(hash2); - const key = toBytes(_key); - this.iHash = hash2.create(); - if (!(this.iHash instanceof Hash)) - throw new TypeError("Expected instance of class which extends utils.Hash"); - const blockLen = this.blockLen = this.iHash.blockLen; - this.outputLen = this.iHash.outputLen; - const pad = new Uint8Array(blockLen); - pad.set(key.length > this.iHash.blockLen ? hash2.create().update(key).digest() : key); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54; - this.iHash.update(pad); - this.oHash = hash2.create(); - for (let i = 0; i < pad.length; i++) - pad[i] ^= 54 ^ 92; - this.oHash.update(pad); - pad.fill(0); - } - update(buf) { - if (this.destroyed) - throw new Error("instance is destroyed"); - this.iHash.update(buf); - return this; - } - digestInto(out) { - if (this.destroyed) - throw new Error("instance is destroyed"); - if (!(out instanceof Uint8Array) || out.length !== this.outputLen) - throw new Error("HMAC: Invalid output buffer"); - if (this.finished) - throw new Error("digest() was already called"); - this.finished = true; - this.iHash.digestInto(out); - this.oHash.update(out); - this.oHash.digestInto(out); - this.destroy(); - } - digest() { - const out = new Uint8Array(this.oHash.outputLen); - this.digestInto(out); - return out; - } - _cloneInto(to) { - to || (to = Object.create(Object.getPrototypeOf(this), {})); - const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this; - to = to; - to.finished = finished; - to.destroyed = destroyed; - to.blockLen = blockLen; - to.outputLen = outputLen; - to.oHash = oHash._cloneInto(to.oHash); - to.iHash = iHash._cloneInto(to.iHash); - return to; - } - destroy() { - this.destroyed = true; - this.oHash.destroy(); - this.iHash.destroy(); - } - }; - var hmac2 = (hash2, key, message) => new HMAC2(hash2, key).update(message).digest(); - hmac2.create = (hash2, key) => new HMAC2(hash2, key); - - // index.ts - utils.hmacSha256Sync = (key, ...msgs) => hmac2(sha256, key, utils.concatBytes(...msgs)); - utils.sha256Sync = (...msgs) => sha256(utils.concatBytes(...msgs)); - return __toCommonJS(nostr_tools_exports); -})(); From 38924abdcc24b2d4a466f94cc19ca0e875b001ab Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:58:31 -0400 Subject: [PATCH 48/82] chore: migrate watch history kind to 30079 --- config/instance-config.js | 13 +++++--- docs/nostr-event-schemas.md | 6 ++-- js/nostr.js | 52 ++++++++++++++++++++++-------- tests/view-counter.test.mjs | 5 +-- tests/watch-history.test.mjs | 62 +++++++++++++++++++++++++++++++++--- 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 38440d43..4c7f4917 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -48,12 +48,15 @@ export const DEFAULT_WHITELIST_MODE_ENABLED = true; /** * Nostr kind used when persisting watch history events. * - * BitVid’s roadmap standardizes on kind 30078 so that watch events, view logs, - * and media metadata stay in the same family of documents. Operators that want - * to experiment with a separate list kind (for example, a NIP-51 collection) - * can flip this number so long as their relays accept the chosen kind. + * BitVid’s roadmap standardizes on kind 30079 so that watch events, view logs, + * and media metadata stay in the same family of documents. During the rollout + * from the legacy 30078 payloads, clients query both kinds so historical data + * keeps syncing; remove the compatibility fetch once all writers emit the new + * kind. Operators that want to experiment with a separate list kind (for + * example, a NIP-51 collection) can flip this number so long as their relays + * accept the chosen kind. */ -export const WATCH_HISTORY_KIND = 30078; +export const WATCH_HISTORY_KIND = 30079; /** * Identifier applied to the watch-history list when storing it on relays. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 716726d0..ebfef8b8 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -48,9 +48,9 @@ read/write split. | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | -| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | Canonical tag set: `['t','view']` plus either `['e', ]` or `['a',
]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | -| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | -| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']` plus either `['e', ]` or `['a',
]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | +| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | +| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | diff --git a/js/nostr.js b/js/nostr.js index ea7df804..04126d88 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -58,6 +58,19 @@ const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER = ? 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" @@ -121,6 +134,14 @@ const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => { 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; @@ -582,7 +603,7 @@ function createVideoViewEventFilters(pointer) { } const pointerFilter = { - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, "#t": ["view"], }; @@ -596,7 +617,7 @@ function createVideoViewEventFilters(pointer) { if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) { filters.push({ - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, "#t": ["view"], "#video": [resolved.value], }); @@ -741,7 +762,7 @@ function isVideoViewEvent(event, pointer) { return false; } - if (Number(event.kind) !== WATCH_HISTORY_KIND) { + if (!isWatchHistoryKind(event.kind)) { return false; } @@ -3587,7 +3608,7 @@ class NostrClient { if (indexIdentifiers.length) { const indexFilter = { - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, authors: [actor], limit: fetchLimit, "#d": indexIdentifiers, @@ -3610,7 +3631,7 @@ class NostrClient { if (legacyIdentifierSet.size) { const legacyFilter = { - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, authors: [actor], limit: fetchLimit, "#d": Array.from(legacyIdentifierSet), @@ -3676,7 +3697,7 @@ class NostrClient { const kind = Number.parseInt(kindStr, 10); if ( Number.isFinite(kind) && - kind === WATCH_HISTORY_KIND && + isWatchHistoryKind(kind) && typeof pubkey === "string" && pubkey === actor && typeof identifier === "string" && @@ -3858,7 +3879,7 @@ class NostrClient { if (chunkIdentifiers.size) { chunkFilters.push({ - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, authors: [actor], "#d": Array.from(chunkIdentifiers), limit: chunkFetchLimit, @@ -3867,7 +3888,7 @@ class NostrClient { if (snapshotIds.size) { chunkFilters.push({ - kinds: [WATCH_HISTORY_KIND], + kinds: WATCH_HISTORY_READ_KINDS, authors: [actor], "#snapshot": Array.from(snapshotIds), limit: chunkFetchLimit, @@ -4335,10 +4356,15 @@ class NostrClient { } for (const key of Array.from(entry.resolved.keys())) { - if ( - !key || - !key.startsWith(`a:${WATCH_HISTORY_KIND}:`) - ) { + 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)) { @@ -4421,7 +4447,7 @@ class NostrClient { } const [kindStr, pubkeyRaw, ...identifierParts] = parts; const kind = Number.parseInt(kindStr, 10); - if (!Number.isFinite(kind) || kind !== WATCH_HISTORY_KIND) { + if (!Number.isFinite(kind) || !isWatchHistoryKind(kind)) { return "none"; } const identifier = identifierParts.join(":"); diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 06adc376..db93f43b 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -4,6 +4,7 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; const { + WATCH_HISTORY_KIND, VIEW_COUNT_DEDUPE_WINDOW_SECONDS, VIEW_COUNT_BACKFILL_MAX_DAYS, VIEW_COUNT_CACHE_TTL_MS, @@ -318,7 +319,7 @@ async function testHydrationSkipsStaleEventsAndRollsOff() { harness.reset(); harness.resetMetrics(); - const pointer = { type: "a", value: "30078:pk:test-hydrate" }; + const pointer = { type: "a", value: `${WATCH_HISTORY_KIND}:pk:test-hydrate` }; const pointerKey = harness.pointerKeyFromInput(pointer); const nowSeconds = Math.floor(Date.now() / 1000); const horizonSeconds = VIEW_COUNT_BACKFILL_MAX_DAYS * 86_400; @@ -513,7 +514,7 @@ async function testRecordVideoViewEmitsJsonPayload() { const pointer = { type: "a", - value: "30078:pub:view-json", + value: `${WATCH_HISTORY_KIND}:pub:view-json`, relay: "wss://relay.example", }; const createdAt = Math.floor(Date.now() / 1000); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 0d58b000..8d95e0d5 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -113,6 +113,7 @@ function createDecryptClient(actorPubkey) { } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const LEGACY_WATCH_HISTORY_KIND = 30078; function deriveChunkPrefix(identifier) { if (typeof identifier !== "string") { return "watch-history:v2"; @@ -281,7 +282,7 @@ const longPointers = Array.from({ length: 8 }, (_, index) => { } return { type: "a", - value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`, + value: `${WATCH_HISTORY_KIND}:${ACTOR}:history-${index}-${"y".repeat(60)}`, relay, watchedAt, }; @@ -526,7 +527,7 @@ const noSignerItems = [ }, { type: "a", - value: `30078:${LOGGED_ACTOR}:history`, + value: `${WATCH_HISTORY_KIND}:${LOGGED_ACTOR}:history`, watchedAt: baseWatchedAt + 456, }, ]; @@ -684,6 +685,32 @@ if (typeof localStorage !== "undefined") { const headEvent = publishResult.event; +const legacyPointerIdentifiers = new Set(); +const legacyIndexEvent = JSON.parse(JSON.stringify(headEvent)); +legacyIndexEvent.kind = LEGACY_WATCH_HISTORY_KIND; +legacyIndexEvent.tags = legacyIndexEvent.tags.map((tag) => { + if ( + Array.isArray(tag) && + tag[0] === "a" && + typeof tag[1] === "string" && + !legacyPointerIdentifiers.size + ) { + const [kindStr, pubkey, ...identifierParts] = tag[1].split(":"); + if (kindStr === String(WATCH_HISTORY_KIND) && identifierParts.length) { + const identifier = identifierParts.join(":"); + legacyPointerIdentifiers.add(identifier); + const legacyAddress = `${LEGACY_WATCH_HISTORY_KIND}:${pubkey}:${identifier}`; + const clone = tag.slice(); + clone[1] = legacyAddress; + return clone; + } + } + return tag; +}); +const legacyChunkIdentifier = legacyPointerIdentifiers.size + ? Array.from(legacyPointerIdentifiers)[0] + : null; + const snapshotId = headEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "snapshot" )?.[1]; @@ -703,7 +730,7 @@ freshClient.pool = { : []; if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { - return [headEvent]; + return [legacyIndexEvent]; } const requestedIdentifiers = new Set(); @@ -725,6 +752,18 @@ freshClient.pool = { const responses = []; for (const event of publishResult.events) { + if (event.id === headEvent.id) { + continue; + } + const cloned = JSON.parse(JSON.stringify(event)); + if (legacyChunkIdentifier) { + const identifierTag = cloned.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "d" + ); + if (identifierTag?.[1] === legacyChunkIdentifier) { + cloned.kind = LEGACY_WATCH_HISTORY_KIND; + } + } const identifier = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" )?.[1]; @@ -733,11 +772,11 @@ freshClient.pool = { )?.[1]; if (identifier && requestedIdentifiers.has(identifier)) { - responses.push(event); + responses.push(cloned); continue; } if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) { - responses.push(event); + responses.push(cloned); } } @@ -769,6 +808,19 @@ assert.deepEqual( "rebuilt snapshot should preserve timestamps" ); +assert( + observedFilters.some((filters) => + filters.some((filter) => { + const kinds = Array.isArray(filter?.kinds) ? filter.kinds : []; + return ( + kinds.includes(WATCH_HISTORY_KIND) && + kinds.includes(LEGACY_WATCH_HISTORY_KIND) + ); + }) + ), + "fetchWatchHistory should request both new and legacy kinds during migration" +); + assert( observedFilters.length >= 2, "fetch should include a follow-up query for chunk events" From ff30678afc7042ebe4316859c1437ad4a6796cfe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:07:31 -0400 Subject: [PATCH 49/82] Delay access control refresh until nostr pool ready --- js/accessControl.js | 124 ++++++++++++++++++++++++-------------------- js/app.js | 6 +++ 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index bb19970d..c3a74b54 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -51,81 +51,93 @@ class AccessControl { this.hasLoaded = false; this.lastError = null; this._isRefreshing = false; - this._refreshPromise = this._performRefresh(); - this._refreshPromise.catch((error) => { - console.error("Failed to load admin lists:", error); - }); + this._refreshPromise = Promise.resolve(); } - async _performRefresh() { + _performRefresh() { if (this._isRefreshing) { return this._refreshPromise; } this._isRefreshing = true; - try { - const state = await loadAdminState(); - const editors = Array.isArray(state?.editors) ? state.editors : []; - const whitelist = Array.isArray(state?.whitelist) - ? state.whitelist - : []; - const blacklist = Array.isArray(state?.blacklist) - ? state.blacklist - : []; - this.editors = new Set( - dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) - ); - const normalizedWhitelist = dedupeNpubs(whitelist); - this.whitelist = new Set(normalizedWhitelist); + const operation = (async () => { + try { + const state = await loadAdminState(); + const editors = Array.isArray(state?.editors) ? state.editors : []; + const whitelist = Array.isArray(state?.whitelist) + ? state.whitelist + : []; + const blacklist = Array.isArray(state?.blacklist) + ? state.blacklist + : []; - const blacklistDedupe = dedupeNpubs(blacklist); - const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); - const adminGuardSet = new Set([ - normalizeNpub(ADMIN_SUPER_NPUB), - ...Array.from(this.editors), - ]); - const sanitizedBlacklist = blacklistDedupe.filter((npub) => { - const normalized = normalizeNpub(npub); - if (!normalized) { - return false; - } - if (whitelistSet.has(normalized)) { - return false; - } - if (adminGuardSet.has(normalized)) { - return false; - } - return true; - }); - this.blacklist = new Set(sanitizedBlacklist); + this.editors = new Set( + dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) + ); + const normalizedWhitelist = dedupeNpubs(whitelist); + this.whitelist = new Set(normalizedWhitelist); - this.whitelistEnabled = getWhitelistMode(); - this.hasLoaded = true; - this.lastError = null; - } catch (error) { - this.lastError = error; - if (!this.hasLoaded) { - this.editors.clear(); - this.whitelist.clear(); - this.blacklist.clear(); + const blacklistDedupe = dedupeNpubs(blacklist); + const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); + const adminGuardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...Array.from(this.editors), + ]); + const sanitizedBlacklist = blacklistDedupe.filter((npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (adminGuardSet.has(normalized)) { + return false; + } + return true; + }); + this.blacklist = new Set(sanitizedBlacklist); + + this.whitelistEnabled = getWhitelistMode(); + this.hasLoaded = true; + this.lastError = null; + } catch (error) { + this.lastError = error; + if (!this.hasLoaded) { + this.editors.clear(); + this.whitelist.clear(); + this.blacklist.clear(); + } + throw error; + } finally { + this._isRefreshing = false; } - throw error; - } finally { - this._isRefreshing = false; - } + })(); + + this._refreshPromise = operation; + return operation; } refresh() { - const promise = this._performRefresh(); - this._refreshPromise = promise; - promise.catch((error) => { + if (this._isRefreshing) { + return this._refreshPromise; + } + + const operation = this._performRefresh(); + const tracked = operation.catch((error) => { console.error("Failed to refresh admin lists:", error); + throw error; }); - return promise; + this._refreshPromise = tracked; + return tracked; } async ensureReady() { + if (!this.hasLoaded && !this._isRefreshing) { + this.refresh(); + } + try { await this._refreshPromise; } catch (error) { diff --git a/js/app.js b/js/app.js index 2f33999e..1dcd9332 100644 --- a/js/app.js +++ b/js/app.js @@ -1261,6 +1261,12 @@ class bitvidApp { try { await accessControl.refresh(); + console.assert( + !accessControl.lastError || + accessControl.lastError?.code !== "nostr-unavailable", + "[app.init()] Access control refresh should not run before nostrClient.init()", + accessControl.lastError + ); } catch (error) { console.warn("Failed to refresh admin lists after connecting to Nostr:", error); } From 6684da3e5f39a1326c9f884303583526f3f1437c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:18:14 -0400 Subject: [PATCH 50/82] Handle legacy watch history content more gracefully --- js/nostr.js | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 04126d88..9eeac4a1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3720,23 +3720,38 @@ class NostrClient { const isIndexEvent = isIndexIdentifier(identifier); if (isIndexEvent && typeof event.content === "string") { - try { - const parsed = JSON.parse(event.content); - 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 - ); + 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; } } } From 92cc05f1f65fbcbaebfb6d75a39a141d7f15e9a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 08:00:21 -0400 Subject: [PATCH 51/82] Refactor relay publish acknowledgements --- js/nostr.js | 316 +++++++++++++++---------- js/nostrPublish.js | 195 +++++++++++++++ js/relayManager.js | 66 ++++-- js/subscriptions.js | 117 +++++++-- js/userBlocks.js | 50 +++- tests/nostr-publish-rejection.test.mjs | 243 +++++++++++++++++++ 6 files changed, 815 insertions(+), 172 deletions(-) create mode 100644 js/nostrPublish.js create mode 100644 tests/nostr-publish-rejection.test.mjs diff --git a/js/nostr.js b/js/nostr.js index 9eeac4a1..70221267 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -28,6 +28,11 @@ import { buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } from "./nostrEventSchemas.js"; +import { + publishEventToRelay, + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; /** * The default relay set BitVid bootstraps with before loading a user's @@ -1139,92 +1144,6 @@ function decodeNpubToHex(npub) { return ""; } -const DM_PUBLISH_TIMEOUT_MS = 10_000; - -function publishEventToRelay(pool, url, event) { - return new Promise((resolve) => { - let settled = false; - const finalize = (success, error = null) => { - if (settled) { - return; - } - settled = true; - resolve({ url, success, error }); - }; - - const timeoutId = setTimeout(() => { - finalize(false, new Error("publish timeout")); - }, DM_PUBLISH_TIMEOUT_MS); - - try { - const pub = pool.publish([url], event); - - if (pub && typeof pub.on === "function") { - const registerHandler = (eventName, handler) => { - try { - pub.on(eventName, handler); - return true; - } catch (error) { - if (isDevMode) { - console.warn( - `[nostr] Relay publish handle rejected ${eventName} listener:`, - error - ); - } - return false; - } - }; - - const handleSuccess = () => { - clearTimeout(timeoutId); - finalize(true); - }; - - const handleFailure = (reason) => { - clearTimeout(timeoutId); - const err = - reason instanceof Error - ? reason - : new Error(String(reason || "publish failed")); - finalize(false, err); - }; - - let handlerRegistered = false; - handlerRegistered = - registerHandler("ok", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("seen", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("failed", handleFailure) || handlerRegistered; - - if (handlerRegistered) { - return; - } - } - - if (pub && typeof pub.then === "function") { - pub - .then(() => { - clearTimeout(timeoutId); - finalize(true); - }) - .catch((error) => { - clearTimeout(timeoutId); - finalize(false, error); - }); - return; - } - } catch (error) { - clearTimeout(timeoutId); - finalize(false, error); - return; - } - - clearTimeout(timeoutId); - finalize(true); - }); -} - const EXTENSION_MIME_MAP = { mp4: "video/mp4", m4v: "video/x-m4v", @@ -5130,17 +5049,54 @@ class NostrClient { const signedEvent = await window.nostr.signEvent(event); if (isDevMode) console.log("Signed event:", signedEvent); - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) console.log(`Video published to ${url}`); - } catch (err) { - if (isDevMode) console.error(`Failed to publish: ${url}`, err); - } - }) + 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 = @@ -5181,23 +5137,54 @@ class NostrClient { console.log("Signed NIP-94 mirror event:", signedMirrorEvent); } - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedMirrorEvent); - if (isDevMode) { - console.log(`NIP-94 mirror published to ${url}`); - } - } catch (mirrorErr) { - if (isDevMode) { - console.error( - `Failed to publish NIP-94 mirror to ${url}`, - mirrorErr + 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( @@ -5356,21 +5343,52 @@ class NostrClient { console.log("Signed edited event:", signedEvent); } - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) { - console.log(`Edited video published to ${url}`); - } - } catch (err) { - if (isDevMode) { - console.error(`Publish failed to ${url}`, err); - } - } - }) + 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); @@ -5461,16 +5479,52 @@ class NostrClient { }; const signedEvent = await window.nostr.signEvent(event); - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - } catch (err) { - if (isDevMode) console.error(`Failed to revert on ${url}`, err); - } - }) + 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; } diff --git a/js/nostrPublish.js b/js/nostrPublish.js new file mode 100644 index 00000000..b8a9d4cc --- /dev/null +++ b/js/nostrPublish.js @@ -0,0 +1,195 @@ +import { isDevMode } from "./config.js"; + +export const RELAY_PUBLISH_TIMEOUT_MS = 10_000; + +function normalizeReason(error) { + if (error instanceof Error) { + return error.message || "publish failed"; + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + if (error === undefined || error === null) { + return "publish failed"; + } + try { + return JSON.stringify(error); + } catch (_) { + return String(error); + } +} + +export class RelayPublishError extends Error { + constructor(message, results = [], options = {}) { + const finalMessage = + typeof message === "string" && message.trim() + ? message.trim() + : "Failed to publish event to any relay."; + super(finalMessage); + this.name = "RelayPublishError"; + + const contextValue = + typeof options.context === "string" && options.context.trim() + ? options.context.trim() + : null; + if (contextValue) { + this.context = contextValue; + } + + this.relayResults = Array.isArray(results) + ? results.map((result) => ({ + url: result?.url || "", + success: !!result?.success, + error: result?.error || null, + reason: normalizeReason(result?.error), + })) + : []; + + this.relayFailures = this.relayResults + .filter((entry) => !entry.success) + .map((entry) => ({ + url: entry.url, + error: entry.error, + reason: entry.reason, + })); + + this.acceptedRelays = this.relayResults + .filter((entry) => entry.success) + .map((entry) => entry.url); + } +} + +export function publishEventToRelay(pool, url, event, options = {}) { + const timeoutMs = + typeof options.timeoutMs === "number" && options.timeoutMs > 0 + ? options.timeoutMs + : RELAY_PUBLISH_TIMEOUT_MS; + + return new Promise((resolve) => { + let settled = false; + const finalize = (success, error = null) => { + if (settled) { + return; + } + settled = true; + resolve({ url, success, error }); + }; + + const timeoutId = setTimeout(() => { + finalize(false, new Error("publish timeout")); + }, timeoutMs); + + try { + const pub = pool?.publish?.([url], event); + + if (pub && typeof pub.on === "function") { + const registerHandler = (eventName, handler) => { + try { + pub.on(eventName, handler); + return true; + } catch (error) { + if (isDevMode) { + console.warn( + `[nostrPublish] Relay publish handle rejected ${eventName} listener:`, + error, + ); + } + return false; + } + }; + + const handleSuccess = () => { + clearTimeout(timeoutId); + finalize(true); + }; + + const handleFailure = (reason) => { + clearTimeout(timeoutId); + const err = + reason instanceof Error + ? reason + : new Error(String(reason || "publish failed")); + finalize(false, err); + }; + + let handlerRegistered = false; + handlerRegistered = + registerHandler("ok", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("seen", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("failed", handleFailure) || handlerRegistered; + + if (handlerRegistered) { + return; + } + } + + if (pub && typeof pub.then === "function") { + pub + .then(() => { + clearTimeout(timeoutId); + finalize(true); + }) + .catch((error) => { + clearTimeout(timeoutId); + finalize(false, error); + }); + return; + } + } catch (error) { + clearTimeout(timeoutId); + finalize(false, error); + return; + } + + clearTimeout(timeoutId); + finalize(true); + }); +} + +export function publishEventToRelays(pool, urls, event, options = {}) { + const list = Array.isArray(urls) ? urls : []; + return Promise.all( + list.map((url) => publishEventToRelay(pool, url, event, options)), + ); +} + +export function summarizePublishResults(results = []) { + const accepted = []; + const failed = []; + + for (const result of results) { + if (result?.success) { + accepted.push(result); + } else { + failed.push(result); + } + } + + return { accepted, failed }; +} + +export function assertAnyRelayAccepted(results = [], options = {}) { + const summary = summarizePublishResults(results); + if (summary.accepted.length > 0) { + return summary; + } + + const contextValue = + typeof options.context === "string" && options.context.trim() + ? options.context.trim() + : ""; + const messageOverride = + typeof options.message === "string" && options.message.trim() + ? options.message.trim() + : ""; + + const message = + messageOverride || + (contextValue + ? `Failed to publish ${contextValue} to any relay.` + : "Failed to publish event to any relay."); + + throw new RelayPublishError(message, results, { context: contextValue }); +} diff --git a/js/relayManager.js b/js/relayManager.js index 43e93596..4c6fff37 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -1,6 +1,10 @@ import { isDevMode } from "./config.js"; import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js"; import { buildRelayListEvent } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; const MODE_SEQUENCE = ["both", "read", "write"]; @@ -497,28 +501,56 @@ class RelayPreferencesManager { throw error; } - const publishResults = await Promise.allSettled( - targets.map((url) => nostrClient.pool.publish([url], signedEvent)) + const publishResults = await publishEventToRelays( + nostrClient.pool, + targets, + signedEvent ); - const accepted = []; - const failed = []; - - publishResults.forEach((result, index) => { - const url = targets[index]; - if (result.status === "fulfilled") { - accepted.push(url); - } else { - failed.push({ url, reason: result.reason || null }); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "relay preferences update", + message: "No relays accepted the update.", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`, + relayError || reason + ); + } + ); } - }); - - const ok = accepted.length > 0; - if (ok) { - this.lastEvent = signedEvent; + throw publishError; } - return { ok, event: signedEvent, accepted, failed, targets }; + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`, + relayError + ); + }); + } + + const accepted = publishSummary.accepted.map(({ url }) => url); + const failed = publishSummary.failed.map(({ url, error: relayError }) => ({ + url, + reason: relayError || null, + })); + + this.lastEvent = signedEvent; + + return { ok: true, event: signedEvent, accepted, failed, targets }; } getLastLoadSource() { diff --git a/js/subscriptions.js b/js/subscriptions.js index 06d0519d..8b4b14f8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -3,13 +3,61 @@ import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; -import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, } from "./nostrEventSchemas.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; + +let attachHealthBadgesLoader = null; +let attachHealthBadgesErrorLogged = false; + +function scheduleAttachHealthBadges(container) { + if (!container) { + return; + } + + if (!attachHealthBadgesLoader) { + attachHealthBadgesLoader = import("./gridHealth.js") + .then((module) => module?.attachHealthBadges || null) + .catch((error) => { + if (!attachHealthBadgesErrorLogged) { + attachHealthBadgesErrorLogged = true; + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] Failed to load grid health helpers:", + error + ); + } + } + return null; + }); + } + + attachHealthBadgesLoader + .then((fn) => { + if (typeof fn === "function") { + try { + fn(container); + } catch (error) { + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] attachHealthBadges threw:", + error + ); + } + } + } + }) + .catch(() => { + // Errors are already logged above; no-op here to keep Promise chains quiet. + }); +} function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -179,25 +227,62 @@ class SubscriptionsManager { content: cipherText, }); + let signedEvent; try { - const signedEvent = await window.nostr.signEvent(evt); - await Promise.all( - nostrClient.relays.map(async (relay) => { - try { - await nostrClient.pool.publish([relay], signedEvent); - } catch (e) { + signedEvent = await window.nostr.signEvent(evt); + } catch (signErr) { + console.error("Failed to sign subscription list:", signErr); + throw signErr; + } + + const publishResults = await publishEventToRelays( + nostrClient.pool, + nostrClient.relays, + signedEvent + ); + + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "subscription list", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { console.error( - `[SubscriptionsManager] Failed to publish to ${relay}`, - e + `[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`, + relayError || reason ); } - }) - ); - this.subsEventId = signedEvent.id; - console.log("Subscription list published, event id:", signedEvent.id); - } catch (signErr) { - console.error("Failed to sign/publish subscription list:", signErr); + ); + } + throw publishError; } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + + this.subsEventId = signedEvent.id; + const acceptedUrls = publishSummary.accepted.map(({ url }) => url); + console.log( + "Subscription list published, event id:", + signedEvent.id, + "accepted relays:", + acceptedUrls + ); } /** @@ -609,7 +694,7 @@ class SubscriptionsManager { }); container.appendChild(fragment); - attachHealthBadges(container); + scheduleAttachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { if (!window.app?.handleUrlHealthBadge) { return; diff --git a/js/userBlocks.js b/js/userBlocks.js index 56aaac8f..30228d81 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,6 +1,10 @@ // js/userBlocks.js import { nostrClient } from "./nostr.js"; import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; function normalizeHex(pubkey) { if (typeof pubkey !== "string") { @@ -257,16 +261,46 @@ class UserBlockListManager { const signedEvent = await window.nostr.signEvent(event); - await Promise.all( - nostrClient.relays.map(async (relay) => { - try { - await nostrClient.pool.publish([relay], signedEvent); - } catch (err) { - console.error(`[UserBlockList] Failed to publish to ${relay}:`, err); - } - }) + const publishResults = await publishEventToRelays( + nostrClient.pool, + nostrClient.relays, + signedEvent ); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "block list", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[UserBlockList] Block list rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[UserBlockList] Block list not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + this.blockEventId = signedEvent.id; return signedEvent; } diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs new file mode 100644 index 00000000..e02ba6a2 --- /dev/null +++ b/tests/nostr-publish-rejection.test.mjs @@ -0,0 +1,243 @@ +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const originalWindowNostr = window.nostr; + +window.nostr = { + ...(originalWindowNostr || {}), + signEvent: async (event) => ({ + ...event, + id: event?.id || `stub-${Math.random().toString(36).slice(2)}`, + sig: "stub-signature", + }), + nip04: { + ...(originalWindowNostr?.nip04 || {}), + encrypt: async () => "cipher-text", + }, +}; + +try { + const { RelayPublishError } = await import("../js/nostrPublish.js"); + const { nostrClient } = await import("../js/nostr.js"); + const { subscriptions } = await import("../js/subscriptions.js"); + const { userBlocks } = await import("../js/userBlocks.js"); + + function createRejectingPool(reason = "relay rejected") { + const calls = []; + return { + calls, + publish(urls, event) { + calls.push({ + urls: Array.isArray(urls) ? [...urls] : [], + event, + }); + return { + on(eventName, handler) { + if (eventName === "failed") { + handler(new Error(reason)); + } + return this; + }, + }; + }, + }; + } + + function snapshotRelayState(client) { + return { + relays: Array.isArray(client.relays) ? [...client.relays] : [], + readRelays: Array.isArray(client.readRelays) + ? [...client.readRelays] + : [], + writeRelays: Array.isArray(client.writeRelays) + ? [...client.writeRelays] + : [], + pool: client.pool, + }; + } + + function restoreRelayState(client, snapshot) { + client.relays = [...snapshot.relays]; + client.readRelays = [...snapshot.readRelays]; + client.writeRelays = [...snapshot.writeRelays]; + client.pool = snapshot.pool; + } + + function applyTestRelayState(client, relays, pool) { + client.relays = [...relays]; + client.readRelays = [...relays]; + client.writeRelays = [...relays]; + client.pool = pool; + } + + async function testPublishVideoRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("video rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + try { + await assert.rejects( + nostrClient.publishVideo( + { + title: "Test video", + magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890", + mode: "live", + }, + "f".repeat(64) + ), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishVideo should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include every relay" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual( + urls, + [...testRelays].sort(), + "relayFailures should include the rejecting relay URLs" + ); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishVideo should attempt each relay" + ); + } finally { + restoreRelayState(nostrClient, relaySnapshot); + } + } + + async function testPublishSubscriptionListRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("subscription rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + const originalSubs = new Set(subscriptions.subscribedPubkeys); + const originalSubsEventId = subscriptions.subsEventId; + subscriptions.subscribedPubkeys = new Set(["abcdef1234567890"]); + subscriptions.subsEventId = null; + + try { + await assert.rejects( + subscriptions.publishSubscriptionList("f".repeat(64)), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishSubscriptionList should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include each relay for subscriptions" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual(urls, [...testRelays].sort()); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishSubscriptionList should attempt each relay" + ); + assert.equal( + subscriptions.subsEventId, + null, + "subsEventId should remain unset on failure" + ); + } finally { + subscriptions.subscribedPubkeys = originalSubs; + subscriptions.subsEventId = originalSubsEventId; + restoreRelayState(nostrClient, relaySnapshot); + } + } + + async function testPublishBlockListRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("block list rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]); + userBlocks.blockEventId = null; + + try { + await assert.rejects( + userBlocks.publishBlockList("f".repeat(64)), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishBlockList should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include each relay for block list" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual(urls, [...testRelays].sort()); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishBlockList should attempt each relay" + ); + assert.equal( + userBlocks.blockEventId, + null, + "blockEventId should remain unset on failure" + ); + } finally { + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + restoreRelayState(nostrClient, relaySnapshot); + } + } + + await testPublishVideoRejectsWhenAllRelaysFail(); + await testPublishSubscriptionListRejectsWhenAllRelaysFail(); + await testPublishBlockListRejectsWhenAllRelaysFail(); + + console.log("nostr publish rejection tests passed"); +} finally { + if (typeof originalWindowNostr === "undefined") { + delete window.nostr; + } else { + window.nostr = originalWindowNostr; + } +} From a22b904ddcdf1d2234d0641ec814bee45c17d5b2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 08:46:22 -0400 Subject: [PATCH 52/82] Cache watch history state across views --- js/historyView.js | 181 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/js/historyView.js b/js/historyView.js index 940bdad7..ff40eff1 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -20,6 +20,102 @@ export const WATCH_HISTORY_EMPTY_COPY = const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…"; const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet."; +const WATCH_HISTORY_SHARED_UI_CACHE = new Map(); + +function normalizeActorCacheKey(actor) { + if (typeof actor !== "string") { + return ""; + } + + const trimmed = actor.trim(); + if (!trimmed) { + return ""; + } + + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return trimmed; +} + +function cloneWatchHistoryVideosForCache(videos) { + if (!Array.isArray(videos)) { + return []; + } + + if (typeof structuredClone === "function") { + try { + return structuredClone(videos); + } catch (error) { + // Fall back to a shallow clone below if structuredClone is unavailable + // or throws (for example, when encountering unsupported values). + } + } + + return videos.map((video) => { + if (!video || typeof video !== "object") { + return video; + } + + const clone = { ...video }; + + if (video.watchHistory && typeof video.watchHistory === "object") { + clone.watchHistory = { ...video.watchHistory }; + } + + if (Array.isArray(video.tags)) { + clone.tags = video.tags.map((tag) => + Array.isArray(tag) ? tag.slice() : tag + ); + } + + return clone; + }); +} + +function getSharedUiSnapshot(actor) { + const key = normalizeActorCacheKey(actor); + if (!key) { + return null; + } + + const entry = WATCH_HISTORY_SHARED_UI_CACHE.get(key); + if (!entry) { + return null; + } + + return { + resolvedVideos: cloneWatchHistoryVideosForCache(entry.resolvedVideos), + hasMore: entry.hasMore !== false, + snapshotFingerprint: + typeof entry.snapshotFingerprint === "string" && entry.snapshotFingerprint + ? entry.snapshotFingerprint + : null, + updatedAt: entry.updatedAt || 0, + }; +} + +function setSharedUiSnapshot(actor, snapshot) { + const key = normalizeActorCacheKey(actor); + if (!key) { + return; + } + + const normalized = { + resolvedVideos: cloneWatchHistoryVideosForCache(snapshot.resolvedVideos), + hasMore: snapshot.hasMore !== false, + snapshotFingerprint: + typeof snapshot.snapshotFingerprint === "string" && + snapshot.snapshotFingerprint + ? snapshot.snapshotFingerprint + : null, + updatedAt: Date.now(), + }; + + WATCH_HISTORY_SHARED_UI_CACHE.set(key, normalized); +} + function getVideoAuthorIdentifiers(video) { if (!video || typeof video !== "object") { return { pubkey: "", npub: "" }; @@ -753,6 +849,7 @@ export function createWatchHistoryRenderer(config = {}) { state.hasMore = false; cleanupObservers(); debugLog("showing empty state", { message }); + persistSharedUiState(); }; const mergeResolvedVideos = (nextVideos) => { @@ -808,6 +905,7 @@ export function createWatchHistoryRenderer(config = {}) { compareByWatchedAtDesc ); applyAccessFilters(); + persistSharedUiState(); }; const ensureGridEventHandlers = () => { @@ -908,6 +1006,7 @@ export function createWatchHistoryRenderer(config = {}) { setLoadingVisible(false); renderResolvedVideos(); + persistSharedUiState(); window.app?.showSuccess?.("Removed from watch history."); } catch (error) { console.error( @@ -1010,6 +1109,75 @@ export function createWatchHistoryRenderer(config = {}) { } }; + const persistSharedUiState = () => { + if (!state.actor) { + return; + } + + setSharedUiSnapshot(state.actor, { + resolvedVideos: state.resolvedVideos, + hasMore: state.hasMore, + snapshotFingerprint: state.snapshotFingerprint, + }); + }; + + const hydrateFromSharedUiCache = () => { + if (!state.actor) { + return { + hydrated: false, + sharedHasMore: false, + fingerprintMatches: false, + }; + } + + const snapshot = getSharedUiSnapshot(state.actor); + if (!snapshot) { + return { + hydrated: false, + sharedHasMore: false, + fingerprintMatches: false, + }; + } + + state.resolvedVideos = Array.isArray(snapshot.resolvedVideos) + ? snapshot.resolvedVideos + : []; + state.hasMore = snapshot.hasMore !== false; + + applyAccessFilters(); + + const nextFingerprint = + typeof snapshot.snapshotFingerprint === "string" && + snapshot.snapshotFingerprint + ? snapshot.snapshotFingerprint + : null; + + let fingerprintMatches = true; + if ( + typeof state.snapshotFingerprint === "string" && + state.snapshotFingerprint && + nextFingerprint && + nextFingerprint !== state.snapshotFingerprint + ) { + fingerprintMatches = false; + } + + if (!state.snapshotFingerprint && nextFingerprint) { + state.snapshotFingerprint = nextFingerprint; + } + + if (state.resolvedVideos.length) { + setLoadingVisible(false); + renderResolvedVideos(); + } + + return { + hydrated: true, + sharedHasMore: state.hasMore, + fingerprintMatches, + }; + }; + const loadNextBatch = async ({ initial = false } = {}) => { if (state.isLoading || !state.hasMore) { debugLog("skipping loadNextBatch", { @@ -1049,6 +1217,7 @@ export function createWatchHistoryRenderer(config = {}) { initial, resolvedCount: state.resolvedVideos.length, }); + persistSharedUiState(); } return; } @@ -1056,6 +1225,7 @@ export function createWatchHistoryRenderer(config = {}) { mergeResolvedVideos(videos); renderResolvedVideos(); setLoadingVisible(false); + persistSharedUiState(); debugLog("rendered videos", { totalResolved: state.resolvedVideos.length, initial, @@ -1249,6 +1419,17 @@ export function createWatchHistoryRenderer(config = {}) { state.snapshotFingerprint = null; } + let sharedHydration = null; + try { + sharedHydration = hydrateFromSharedUiCache(); + } catch (error) { + debugLog("failed to hydrate shared watch history state", error); + } + + if (sharedHydration?.hydrated) { + state.hasMore = true; + } + try { await accessControl.ensureReady(); } catch (error) { From bf088532dc59c4034e326263324f507f147fefba Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:02:55 -0400 Subject: [PATCH 53/82] Fix watch history publish errors against nostr-tools 1.8 --- js/nostrPublish.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/js/nostrPublish.js b/js/nostrPublish.js index b8a9d4cc..5faa25f3 100644 --- a/js/nostrPublish.js +++ b/js/nostrPublish.js @@ -115,11 +115,18 @@ export function publishEventToRelay(pool, url, event, options = {}) { let handlerRegistered = false; handlerRegistered = registerHandler("ok", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("seen", handleSuccess) || handlerRegistered; handlerRegistered = registerHandler("failed", handleFailure) || handlerRegistered; + // nostr-tools@1.8 removed the optional "seen" callback from relay + // publish handles. Attempting to register it now produces an + // asynchronous TypeError that bubbles up as an unhandled rejection and + // prevents watch-history snapshots from ever resolving. We therefore + // skip registering a "seen" listener and rely on "ok" (per NIP-20) for + // success notifications. Legacy relays that only emit "seen" fall back + // to the optimistic success path below because no handlers end up + // registered. + if (handlerRegistered) { return; } From d938d01a4c2356893faeebd78e81171f24a87a9e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:17:28 -0400 Subject: [PATCH 54/82] Add dev logging for watch history updates --- js/nostr.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/js/nostr.js b/js/nostr.js index 70221267..c23c7579 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2558,6 +2558,35 @@ class NostrClient { this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } + if (isDevMode) { + 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 || From 6e838aedd9a82a75132aba0540a200a8d5ffdaa9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:28:44 -0400 Subject: [PATCH 55/82] Document watch history logging interpretation --- docs/watch-history-logging.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/watch-history-logging.md diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md new file mode 100644 index 00000000..b5cf1e89 --- /dev/null +++ b/docs/watch-history-logging.md @@ -0,0 +1,15 @@ +# Watch History Logging Cheatsheet + +The watch history publisher emits developer-mode console messages from `publishWatchHistorySnapshot()`. + +- `Watch history snapshot updated` (info) — all chunks and the index event were accepted by at least one relay. The summary includes: + - `actor`: abbreviated pubkey that authored the snapshot. + - `itemCount`: number of watch history entries that made it into the snapshot payload. + - `snapshot`: identifier constructed from the current unix timestamp and a random suffix. + - `latestPointer`: truncated pointer key for the newest watch entry that survived chunking. + - `latestWatchedAt`: ISO timestamp derived from the most recent entry's `watchedAt` (falls back to `Date.now()` when missing). +- `Watch history snapshot publish incomplete` (warning) — at least one chunk or the index event failed to reach any relay. When this fires the snapshot is still cached locally and the client will retry publication later. + +Errors that precede the info log do **not** necessarily indicate a failure. For example, a message like `WebSocket connection to 'ws://127.0.0.1:4869/' failed` simply means one relay in the user's configured relay list is offline. The publisher only requires that at least one relay accepts each chunk before reporting success. + +Refer to the log source in `js/nostr.js` for the exact structure and retry behavior. From 7149255004f52d4050763863071a2e39ddbf63d3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:40:39 -0400 Subject: [PATCH 56/82] Decouple view logging and watch history timers --- docs/watch-history-logging.md | 15 +++++ js/app.js | 109 +++++++++++++++++++++++++--------- js/nostr.js | 29 +++++++++ 3 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 docs/watch-history-logging.md diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md new file mode 100644 index 00000000..b5cf1e89 --- /dev/null +++ b/docs/watch-history-logging.md @@ -0,0 +1,15 @@ +# Watch History Logging Cheatsheet + +The watch history publisher emits developer-mode console messages from `publishWatchHistorySnapshot()`. + +- `Watch history snapshot updated` (info) — all chunks and the index event were accepted by at least one relay. The summary includes: + - `actor`: abbreviated pubkey that authored the snapshot. + - `itemCount`: number of watch history entries that made it into the snapshot payload. + - `snapshot`: identifier constructed from the current unix timestamp and a random suffix. + - `latestPointer`: truncated pointer key for the newest watch entry that survived chunking. + - `latestWatchedAt`: ISO timestamp derived from the most recent entry's `watchedAt` (falls back to `Date.now()` when missing). +- `Watch history snapshot publish incomplete` (warning) — at least one chunk or the index event failed to reach any relay. When this fires the snapshot is still cached locally and the client will retry publication later. + +Errors that precede the info log do **not** necessarily indicate a failure. For example, a message like `WebSocket connection to 'ws://127.0.0.1:4869/' failed` simply means one relay in the user's configured relay list is offline. The publisher only requires that at least one relay accepts each chunk before reporting success. + +Refer to the log source in `js/nostr.js` for the exact structure and retry behavior. diff --git a/js/app.js b/js/app.js index 1dcd9332..233a7e6b 100644 --- a/js/app.js +++ b/js/app.js @@ -6983,8 +6983,12 @@ class bitvidApp { return; } - if (state.timerId) { - clearTimeout(state.timerId); + if (state.viewTimerId) { + clearTimeout(state.viewTimerId); + } + + if (state.historyTimerId) { + clearTimeout(state.historyTimerId); } if (Array.isArray(state.handlers) && state.videoEl) { @@ -7080,24 +7084,41 @@ class bitvidApp { return; } - const THRESHOLD_SECONDS = 12; + const VIEW_THRESHOLD_SECONDS = 12; + const WATCH_HISTORY_THRESHOLD_SECONDS = 10; const state = { videoEl, pointer, pointerKey, viewerIdentityKey, handlers: [], - timerId: null, - fired: false, + viewTimerId: null, + historyTimerId: null, + viewFired: false, + historyFired: false, }; - const finalize = () => { - if (state.fired) { + const maybeCleanup = () => { + if (state.viewFired && state.historyFired) { + this.cancelPendingViewLogging(); + } + }; + + const clearTimer = (timerKey) => { + const property = `${timerKey}TimerId`; + if (state[property]) { + clearTimeout(state[property]); + state[property] = null; + } + }; + + const finalizeView = () => { + if (state.viewFired) { return; } - state.fired = true; - cancelTimer(); - this.cancelPendingViewLogging(); + state.viewFired = true; + clearTimer("view"); + maybeCleanup(); const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = state; @@ -7140,7 +7161,27 @@ class bitvidApp { viewResult ); } + })().catch((error) => { + if (isDevMode) { + console.warn( + "[playVideoWithFallback] Unexpected error while recording video view:", + error + ); + } + }); + }; + const finalizeWatchHistory = () => { + if (state.historyFired) { + return; + } + state.historyFired = true; + clearTimer("history"); + maybeCleanup(); + + const { pointer: thresholdPointer } = state; + + (async () => { try { const historyResult = await updateWatchHistoryList(thresholdPointer); if (!historyResult?.ok && isDevMode) { @@ -7160,22 +7201,17 @@ class bitvidApp { })().catch((error) => { if (isDevMode) { console.warn( - "[playVideoWithFallback] Unexpected error while recording video view:", + "[playVideoWithFallback] Unexpected error while updating watch history:", error ); } }); }; - const cancelTimer = () => { - if (state.timerId) { - clearTimeout(state.timerId); - state.timerId = null; - } - }; - - const scheduleTimer = () => { - if (state.fired) { + const scheduleTimer = (timerKey, thresholdSeconds, callback) => { + const firedKey = `${timerKey}Fired`; + const idKey = `${timerKey}TimerId`; + if (state[firedKey] || state[idKey]) { return; } const currentSeconds = Number.isFinite(videoEl.currentTime) @@ -7183,13 +7219,13 @@ class bitvidApp { : 0; const remainingMs = Math.max( 0, - Math.ceil((THRESHOLD_SECONDS - currentSeconds) * 1000) + Math.ceil((thresholdSeconds - currentSeconds) * 1000) ); if (remainingMs <= 0) { - finalize(); + callback(); return; } - state.timerId = window.setTimeout(finalize, remainingMs); + state[idKey] = window.setTimeout(callback, remainingMs); }; const registerHandler = (eventName, handler) => { @@ -7198,14 +7234,23 @@ class bitvidApp { }; registerHandler("timeupdate", () => { - if (videoEl.currentTime >= THRESHOLD_SECONDS) { - finalize(); + if ( + !state.historyFired && + videoEl.currentTime >= WATCH_HISTORY_THRESHOLD_SECONDS + ) { + finalizeWatchHistory(); + } + if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) { + finalizeView(); } }); const cancelOnPause = () => { - if (!state.fired) { - cancelTimer(); + if (!state.viewFired) { + clearTimer("view"); + } + if (!state.historyFired) { + clearTimer("history"); } }; @@ -7214,9 +7259,15 @@ class bitvidApp { ); const resumeIfNeeded = () => { - if (!state.fired && !state.timerId) { - scheduleTimer(); + if (state.viewFired && state.historyFired) { + return; } + scheduleTimer( + "history", + WATCH_HISTORY_THRESHOLD_SECONDS, + finalizeWatchHistory + ); + scheduleTimer("view", VIEW_THRESHOLD_SECONDS, finalizeView); }; ["play", "playing"].forEach((event) => diff --git a/js/nostr.js b/js/nostr.js index 70221267..c23c7579 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2558,6 +2558,35 @@ class NostrClient { this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } + if (isDevMode) { + 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 || From e97d1bb08446d7634af8b82ae1da6b2e5d544e01 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:49:22 -0400 Subject: [PATCH 57/82] Surface watch history publish logs in production --- js/app.js | 12 +++++------- js/nostr.js | 50 ++++++++++++++++++++++++-------------------------- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/js/app.js b/js/app.js index 233a7e6b..57b5bd45 100644 --- a/js/app.js +++ b/js/app.js @@ -7184,19 +7184,17 @@ class bitvidApp { (async () => { try { const historyResult = await updateWatchHistoryList(thresholdPointer); - if (!historyResult?.ok && isDevMode) { + if (!historyResult?.ok) { console.warn( "[playVideoWithFallback] Watch history update failed:", historyResult ); } } catch (historyError) { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] Exception while updating watch history:", - historyError - ); - } + console.warn( + "[playVideoWithFallback] Exception while updating watch history:", + historyError + ); } })().catch((error) => { if (isDevMode) { diff --git a/js/nostr.js b/js/nostr.js index c23c7579..0e718499 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2558,33 +2558,31 @@ class NostrClient { this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } - if (isDevMode) { - 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, - }; + 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); - } + if (overallSuccess) { + console.info("[nostr] Watch history snapshot updated:", summary); + } else { + console.warn("[nostr] Watch history snapshot publish incomplete:", summary); } const pointerEvent = From 8bdfd672aa549ac6740f23a0500b81fc73452b56 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:06:12 -0400 Subject: [PATCH 58/82] Log relay acceptance for watch history publishes --- js/nostr.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/js/nostr.js b/js/nostr.js index 0e718499..d852cf99 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2552,6 +2552,51 @@ class NostrClient { 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) { From d8f6da2b2cc6922d856d2d6301f933a2cd384207 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:38:29 -0400 Subject: [PATCH 59/82] Improve watch history fetch parity and logging --- js/nostr.js | 481 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 334 insertions(+), 147 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index d852cf99..01c22c4c 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4020,23 +4020,61 @@ class NostrClient { 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: { - ...payload, - snapshot: snapshotId, - chunkIndex, - totalChunks, - needsSignerRetry: !!payload?.needsSignerRetry, - }, + 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; @@ -4069,11 +4107,20 @@ class NostrClient { 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 = @@ -4162,9 +4209,17 @@ class NostrClient { 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) { @@ -4189,31 +4244,161 @@ class NostrClient { 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 : []; - let pointerForEntry = effectiveSnapshot?.pointerEvent || null; - if (!pointerForEntry) { - if (previousEntry?.pointerEvent) { - pointerForEntry = previousEntry.pointerEvent; - } else if (storedPointerEvent) { - pointerForEntry = storedPointerEvent; - } - } + 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; - const remoteVersion = effectiveSnapshot?.version ?? 0; + let remoteUsed = false; + let remoteIssues = null; + let fallbackSource = null; - if (!effectiveSnapshot || (remoteVersion === 0 && !remoteItems.length)) { - if (previousEntry?.items?.length) { - items = previousEntry.items; - usedFallback = true; - } else if (storedItems.length) { - items = storedItems; - usedFallback = true; + 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( @@ -4225,166 +4410,168 @@ class NostrClient { if (!(entry.chunkEvents instanceof Map)) { entry.chunkEvents = new Map(); - } else { + } else if (remoteUsed) { entry.chunkEvents.clear(); } if (!(entry.chunkNeedsSigner instanceof Set)) { entry.chunkNeedsSigner = new Set(); - } else { + } else if (remoteUsed) { entry.chunkNeedsSigner.clear(); } - const chunkPointerItemsForEntry = []; - const chunkPointerKeys = new Set(); - let indexPointerForEntry = null; - let firstChunkPointer = null; + if (remoteUsed && effectiveSnapshot) { + const chunkPointerItemsForEntry = []; + const chunkPointerKeys = new Set(); + let indexPointerForEntry = null; + let firstChunkPointer = null; - if (effectiveSnapshot?.pointerEvent) { - const indexAddress = eventToAddressPointer(effectiveSnapshot.pointerEvent); - 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(effectiveSnapshot.pointerEvent); - if (clonedIndex) { - entry.chunkEvents.set(key, clonedIndex); + 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; } - 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; + 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: addressValue, + value: address, relay: null, }); if (!pointer) { - continue; + return; } 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); + return; } chunkPointerKeys.add(key); chunkPointerItemsForEntry.push(pointer); - if (chunk.payload?.chunkIndex === 0 && !firstChunkPointer) { - firstChunkPointer = 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) + ); } } } - } - if (effectiveSnapshot?.chunkAddresses instanceof Set) { - effectiveSnapshot.chunkAddresses.forEach((address) => { - const pointer = clonePointerItem({ - type: "a", - value: address, - relay: null, - }); - if (!pointer) { - return; + for (const key of Array.from(entry.resolved.keys())) { + if (!key || !key.startsWith("a:")) { + continue; } - const key = pointerKey(pointer); - if (!key || chunkPointerKeys.has(key)) { - return; + const pointerValue = key.slice(2); + if (!pointerValue) { + continue; } - chunkPointerKeys.add(key); - chunkPointerItemsForEntry.push(pointer); - }); - } + const pointerKind = Number.parseInt(pointerValue.split(":")[0], 10); + if (!Number.isFinite(pointerKind) || !isWatchHistoryKind(pointerKind)) { + continue; + } + if (!chunkPointerKeys.has(key)) { + entry.resolved.delete(key); + } + } - 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 chunkPointerKeys) { + const eventForKey = entry.chunkEvents.get(key); + if (eventForKey) { + entry.resolved.set(key, cloneEventForCache(eventForKey)); } } } - - 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); From 10adeddd8273b4ddd5d5d01f606090f40d9145d1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:51:41 -0400 Subject: [PATCH 60/82] Add durable identifiers to view events --- docs/nostr-analytics.md | 2 ++ docs/nostr-event-schemas.md | 2 +- js/nostr.js | 45 +++++++++++++++++++++++++++++++++++++ js/nostrEventSchemas.js | 1 + tests/view-counter.test.mjs | 8 +++++-- 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index 1aee3e8b..e181e8fb 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -22,6 +22,8 @@ When a player crosses the 12-second watch threshold, BitVid invokes [`publishVie To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. +Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history. + ### Hydration, COUNT, and UI sync Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index ebfef8b8..9316ee3d 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -48,7 +48,7 @@ read/write split. | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | -| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']` plus either `['e', ]` or `['a',
]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | diff --git a/js/nostr.js b/js/nostr.js index 01c22c4c..548eb0e1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -660,6 +660,48 @@ function deriveViewEventPointerScope(pointer) { 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; @@ -2976,6 +3018,8 @@ class NostrClient { } } + const dedupeTag = generateViewEventDedupeTag(actorPubkey, pointer, createdAt); + const event = buildViewEvent({ pubkey: actorPubkey, created_at: createdAt, @@ -2983,6 +3027,7 @@ class NostrClient { pointerTag, includeSessionTag: usingSessionActor, additionalTags, + dedupeTag, content, }); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 9f3720b1..f07eae8d 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -146,6 +146,7 @@ const BASE_SCHEMAS = { label: "View counter", kind: WATCH_HISTORY_KIND, topicTag: { name: "t", value: "view" }, + identifierTag: { name: "d" }, sessionTag: { name: "session", value: "true" }, appendTags: DEFAULT_APPEND_TAGS, content: { diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index db93f43b..3b87f52f 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -59,8 +59,12 @@ const canonicalViewEvent = buildViewEvent({ assert.deepEqual( canonicalViewEvent.tags, - [["t", "view"], [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value]], - "view event should only include topic and supplied pointer tags by default" + [ + ["t", "view"], + [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value], + ["d", "ignore-dedupe"], + ], + "view event should include topic, pointer, and dedupe tags when provided" ); const sessionViewEvent = buildViewEvent({ From 48410b1abb4e2aa51f62197faabff9e3c6155f1d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:43:15 -0400 Subject: [PATCH 61/82] chore: remove watch history logic --- docs/nostr-analytics.md | 12 +- docs/nostr-event-schemas.md | 18 +- docs/watch-history-logging.md | 14 +- js/app.js | 95 +- js/historyView.js | 1594 +--------------- js/index.js | 41 - js/nostr.js | 3394 +-------------------------------- tests/view-counter.test.mjs | 7 - tests/watch-history.test.mjs | 1662 ---------------- 9 files changed, 109 insertions(+), 6728 deletions(-) delete mode 100644 tests/watch-history.test.mjs diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index e181e8fb..4ba6fe1b 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -18,7 +18,7 @@ Playback telemetry runs even when a viewer has not connected a Nostr account. Th When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing. -`recordVideoView` no longer mutates watch history—it strictly publishes analytics events. Clients that want personal playback timelines must independently call [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) once their threshold logic fires. See the [watch history pipeline](#watch-history-pipeline) for integration details. +The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing. To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. @@ -28,13 +28,3 @@ Every view event includes a unique `#d` tag derived from the pointer scope, the Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload. -## Watch history pipeline - -BitVid ships a parallel watch-history system so operators can disable analytics entirely while still giving viewers a private "continue watching" shelf. [`updateWatchHistoryList`](../js/nostr.js#L3235-L3344) gathers the latest pointer plus any cached entries, normalizes them, and persists an encrypted snapshot through [`publishWatchHistorySnapshot`](../js/nostr.js#L2170-L2584). Each publish produces: - -- A monotonic index event that references the active snapshot ID and every chunk address. -- One or more encrypted chunk events containing `{ type, value, relay?, watchedAt }` tuples for each pointer. `watchedAt` is truncated to seconds and defaults to `Date.now()` when the caller omits it. - -Snapshots dedupe pointers, cap the list at the configured maximum, and are signed by the same session actor used for views. Clients should supply the same pointer structure they feed into [`recordVideoView`](../js/nostr.js#L4516-L4555)—typically `{ type: 'a', value:
, relay }`—while setting a `watchedAt` timestamp if they want to preserve historical ordering during backfills. - -Because view counting and watch history are intentionally decoupled, invoking `updateWatchHistoryList` is the caller's responsibility. Most integrations fire it immediately after `recordVideoView` confirms success, but privacy-focused deployments can disable view logging and still call the watch-history API. Cross-check the [publish latency expectations](#publish-latency-expectations) section when wiring both flows so operators understand they can operate independently. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 9316ee3d..d7c8dc67 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -49,8 +49,8 @@ read/write split. | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | -| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | -| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | +| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`, legacy) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | +| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`, legacy) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | @@ -63,16 +63,4 @@ builders inherit the same debugging knobs. ### Watch history identifiers -Watch-history snapshots now publish a dedicated index event with -`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history`). The -index advertises the active snapshot id, total chunk count, and a pointer to -each chunk via `a` tags so clients can fetch the encrypted payloads without -guessing identifiers. Deployments that previously used -`watch-history:v2:index` continue to resolve thanks to the legacy identifier -list. - -Every chunk event uses a unique `d` tag such as -`watch-history:v2//`. Legacy snapshots created before the -index event shipped stored the first chunk at `#d watch-history`; new clients -should continue reading that identifier for backward compatibility when -hydrating older histories. +The watch history publishing pipeline has been retired. These identifiers remain documented so existing relays can keep serving legacy payloads, but new clients no longer emit or refresh watch-history events. diff --git a/docs/watch-history-logging.md b/docs/watch-history-logging.md index b5cf1e89..78f354a8 100644 --- a/docs/watch-history-logging.md +++ b/docs/watch-history-logging.md @@ -1,15 +1,3 @@ # Watch History Logging Cheatsheet -The watch history publisher emits developer-mode console messages from `publishWatchHistorySnapshot()`. - -- `Watch history snapshot updated` (info) — all chunks and the index event were accepted by at least one relay. The summary includes: - - `actor`: abbreviated pubkey that authored the snapshot. - - `itemCount`: number of watch history entries that made it into the snapshot payload. - - `snapshot`: identifier constructed from the current unix timestamp and a random suffix. - - `latestPointer`: truncated pointer key for the newest watch entry that survived chunking. - - `latestWatchedAt`: ISO timestamp derived from the most recent entry's `watchedAt` (falls back to `Date.now()` when missing). -- `Watch history snapshot publish incomplete` (warning) — at least one chunk or the index event failed to reach any relay. When this fires the snapshot is still cached locally and the client will retry publication later. - -Errors that precede the info log do **not** necessarily indicate a failure. For example, a message like `WebSocket connection to 'ws://127.0.0.1:4869/' failed` simply means one relay in the user's configured relay list is offline. The publisher only requires that at least one relay accepts each chunk before reporting success. - -Refer to the log source in `js/nostr.js` for the exact structure and retry behavior. +The legacy watch history publisher has been removed. Clients no longer emit watch-history snapshot events, and related console logs have been retired alongside the feature. diff --git a/js/app.js b/js/app.js index 57b5bd45..cdc147a9 100644 --- a/js/app.js +++ b/js/app.js @@ -1,11 +1,7 @@ // js/app.js import { loadView } from "./viewManager.js"; -import { - nostrClient, - recordVideoView, - updateWatchHistoryList, -} from "./nostr.js"; +import { nostrClient, recordVideoView } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; import { accessControl, normalizeNpub } from "./accessControl.js"; @@ -4289,29 +4285,6 @@ class bitvidApp { } button.dataset.navBound = "true"; button.addEventListener("click", () => { - if (name === "history") { - let actorCandidate; - if (typeof this.pubkey === "string" && this.pubkey.trim()) { - actorCandidate = this.pubkey.trim(); - } else if ( - typeof window?.app?.pubkey === "string" && - window.app.pubkey.trim() - ) { - actorCandidate = window.app.pubkey.trim(); - } - - void nostrClient - .fetchWatchHistory(actorCandidate, { forceRefresh: true }) - .catch((error) => { - if (isDevMode) { - console.warn( - "[profileModal] Failed to prefetch watch history on navigation:", - error - ); - } - }); - } - this.selectProfilePane(name); }); }); @@ -6987,10 +6960,6 @@ class bitvidApp { clearTimeout(state.viewTimerId); } - if (state.historyTimerId) { - clearTimeout(state.historyTimerId); - } - if (Array.isArray(state.handlers) && state.videoEl) { for (const { eventName, handler } of state.handlers) { try { @@ -7085,7 +7054,6 @@ class bitvidApp { } const VIEW_THRESHOLD_SECONDS = 12; - const WATCH_HISTORY_THRESHOLD_SECONDS = 10; const state = { videoEl, pointer, @@ -7093,15 +7061,7 @@ class bitvidApp { viewerIdentityKey, handlers: [], viewTimerId: null, - historyTimerId: null, viewFired: false, - historyFired: false, - }; - - const maybeCleanup = () => { - if (state.viewFired && state.historyFired) { - this.cancelPendingViewLogging(); - } }; const clearTimer = (timerKey) => { @@ -7118,7 +7078,7 @@ class bitvidApp { } state.viewFired = true; clearTimer("view"); - maybeCleanup(); + this.cancelPendingViewLogging(); const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = state; @@ -7171,41 +7131,6 @@ class bitvidApp { }); }; - const finalizeWatchHistory = () => { - if (state.historyFired) { - return; - } - state.historyFired = true; - clearTimer("history"); - maybeCleanup(); - - const { pointer: thresholdPointer } = state; - - (async () => { - try { - const historyResult = await updateWatchHistoryList(thresholdPointer); - if (!historyResult?.ok) { - console.warn( - "[playVideoWithFallback] Watch history update failed:", - historyResult - ); - } - } catch (historyError) { - console.warn( - "[playVideoWithFallback] Exception while updating watch history:", - historyError - ); - } - })().catch((error) => { - if (isDevMode) { - console.warn( - "[playVideoWithFallback] Unexpected error while updating watch history:", - error - ); - } - }); - }; - const scheduleTimer = (timerKey, thresholdSeconds, callback) => { const firedKey = `${timerKey}Fired`; const idKey = `${timerKey}TimerId`; @@ -7232,12 +7157,6 @@ class bitvidApp { }; registerHandler("timeupdate", () => { - if ( - !state.historyFired && - videoEl.currentTime >= WATCH_HISTORY_THRESHOLD_SECONDS - ) { - finalizeWatchHistory(); - } if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) { finalizeView(); } @@ -7247,9 +7166,6 @@ class bitvidApp { if (!state.viewFired) { clearTimer("view"); } - if (!state.historyFired) { - clearTimer("history"); - } }; ["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) => @@ -7257,14 +7173,9 @@ class bitvidApp { ); const resumeIfNeeded = () => { - if (state.viewFired && state.historyFired) { + if (state.viewFired) { return; } - scheduleTimer( - "history", - WATCH_HISTORY_THRESHOLD_SECONDS, - finalizeWatchHistory - ); scheduleTimer("view", VIEW_THRESHOLD_SECONDS, finalizeView); }; diff --git a/js/historyView.js b/js/historyView.js index ff40eff1..4d025f50 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1,1584 +1,132 @@ // js/historyView.js -import { nostrClient } from "./nostr.js"; -import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; -import { subscriptions } from "./subscriptions.js"; -import { accessControl } from "./accessControl.js"; -import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; -import { - toNumber, - getWatchedAtScore, - compareByWatchedAtDesc, -} from "./watchHistorySort.js"; - -const DEFAULT_BATCH_SIZE = 20; -const BATCH_SIZE = WATCH_HISTORY_BATCH_RESOLVE ? DEFAULT_BATCH_SIZE : 1; - export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; -const WATCH_HISTORY_LOADING_STATUS = "Fetching watch history from relays…"; -const WATCH_HISTORY_EMPTY_STATUS = "No watch history yet."; - -const WATCH_HISTORY_SHARED_UI_CACHE = new Map(); - -function normalizeActorCacheKey(actor) { - if (typeof actor !== "string") { - return ""; - } - - const trimmed = actor.trim(); - if (!trimmed) { - return ""; - } - - if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { - return trimmed.toLowerCase(); - } - - return trimmed; -} - -function cloneWatchHistoryVideosForCache(videos) { - if (!Array.isArray(videos)) { - return []; - } - - if (typeof structuredClone === "function") { - try { - return structuredClone(videos); - } catch (error) { - // Fall back to a shallow clone below if structuredClone is unavailable - // or throws (for example, when encountering unsupported values). - } - } - - return videos.map((video) => { - if (!video || typeof video !== "object") { - return video; - } - - const clone = { ...video }; - - if (video.watchHistory && typeof video.watchHistory === "object") { - clone.watchHistory = { ...video.watchHistory }; - } - - if (Array.isArray(video.tags)) { - clone.tags = video.tags.map((tag) => - Array.isArray(tag) ? tag.slice() : tag - ); - } - - return clone; - }); -} - -function getSharedUiSnapshot(actor) { - const key = normalizeActorCacheKey(actor); - if (!key) { +function resolveElement(selector) { + if (!selector || typeof selector !== "string") { return null; } - - const entry = WATCH_HISTORY_SHARED_UI_CACHE.get(key); - if (!entry) { + try { + return document.querySelector(selector); + } catch (error) { + console.warn("[historyView] Failed to query selector:", selector, error); return null; } - - return { - resolvedVideos: cloneWatchHistoryVideosForCache(entry.resolvedVideos), - hasMore: entry.hasMore !== false, - snapshotFingerprint: - typeof entry.snapshotFingerprint === "string" && entry.snapshotFingerprint - ? entry.snapshotFingerprint - : null, - updatedAt: entry.updatedAt || 0, - }; } -function setSharedUiSnapshot(actor, snapshot) { - const key = normalizeActorCacheKey(actor); - if (!key) { +function setHidden(element, hidden) { + if (!(element instanceof HTMLElement)) { return; } - - const normalized = { - resolvedVideos: cloneWatchHistoryVideosForCache(snapshot.resolvedVideos), - hasMore: snapshot.hasMore !== false, - snapshotFingerprint: - typeof snapshot.snapshotFingerprint === "string" && - snapshot.snapshotFingerprint - ? snapshot.snapshotFingerprint - : null, - updatedAt: Date.now(), - }; - - WATCH_HISTORY_SHARED_UI_CACHE.set(key, normalized); + element.classList.toggle("hidden", hidden); + if (hidden) { + element.setAttribute("aria-hidden", "true"); + } else { + element.removeAttribute("aria-hidden"); + } } -function getVideoAuthorIdentifiers(video) { - if (!video || typeof video !== "object") { - return { pubkey: "", npub: "" }; - } - - const extractString = (candidate) => - typeof candidate === "string" && candidate.trim() ? candidate.trim() : ""; - - const candidateNpubs = [ - extractString(video.npub), - extractString(video.authorNpub), - extractString(video?.author?.npub), - extractString(video?.watchHistory?.npub), - extractString(video?.watchHistory?.npubEncoded), - extractString(video?.watchHistory?.authorNpub), - extractString(video?.watchHistory?.ownerNpub), - ]; - - let pubkey = extractString(video.pubkey); - if (!pubkey && video?.author && typeof video.author === "object") { - pubkey = extractString(video.author.pubkey); - } - - let npub = candidateNpubs.find((value) => value) || ""; - - if (!npub) { - if (pubkey && pubkey.startsWith("npub")) { - npub = pubkey; - } else if (pubkey && window?.NostrTools?.nip19?.npubEncode) { - try { - npub = window.NostrTools.nip19.npubEncode(pubkey); - } catch (error) { - npub = ""; - } - } - } - - if (!pubkey && npub && window?.NostrTools?.nip19?.decode) { - try { - const decoded = window.NostrTools.nip19.decode(npub); - if (decoded?.type === "npub") { - if (typeof decoded?.data === "string" && decoded.data) { - pubkey = decoded.data; - } else if ( - decoded?.data && - typeof decoded.data === "object" && - typeof decoded.data.hex === "string" - ) { - pubkey = decoded.data.hex; - } - } - } catch (error) { - pubkey = ""; - } - } - - return { pubkey, npub }; -} - -function isVideoBlocked(video) { - const { pubkey, npub } = getVideoAuthorIdentifiers(video); - - if (pubkey && typeof window?.app?.isAuthorBlocked === "function") { - try { - if (window.app.isAuthorBlocked(pubkey)) { - return true; - } - } catch (error) { - console.warn("[historyView] Failed to check personal block list:", error); - } - } - - if (npub && typeof accessControl?.isBlacklisted === "function") { - try { - if (accessControl.isBlacklisted(npub)) { - return true; - } - } catch (error) { - console.warn("[historyView] Failed to check access blacklist:", error); - } - } - - return false; -} - -function filterAccessibleVideos(videos) { - if (!Array.isArray(videos)) { - return []; - } - - return videos.filter((video) => { - if (!video || typeof video !== "object") { - return false; - } - - return !isVideoBlocked(video); - }); -} - -function getAbsoluteShareUrl(nevent) { - if (!nevent) { - return ""; - } - - if (window.app?.buildShareUrlFromNevent) { - const candidate = window.app.buildShareUrlFromNevent(nevent); - if (candidate) { - return candidate; - } - } - - const origin = window.location?.origin || ""; - const pathname = window.location?.pathname || ""; - let base = origin || pathname ? `${origin}${pathname}` : ""; - if (!base) { - const href = window.location?.href || ""; - base = href ? href.split(/[?#]/)[0] : ""; - } - - if (!base) { - return `?v=${encodeURIComponent(nevent)}`; - } - - return `${base}?v=${encodeURIComponent(nevent)}`; -} - -function renderWatchHistoryGrid(videos, containerOrElement) { - let container = null; - if (typeof containerOrElement === "string") { - container = document.getElementById(containerOrElement); - } else if (containerOrElement instanceof Element) { - container = containerOrElement; - } - - if (!container) { +function setTextContent(element, text) { + if (!(element instanceof HTMLElement)) { return; } - - const safeVideos = Array.isArray(videos) ? videos : []; - const dedupedVideos = - window.app?.dedupeVideosByRoot?.(safeVideos) ?? - subscriptions.dedupeToNewestByRoot(safeVideos); - - const filteredVideos = filterAccessibleVideos(dedupedVideos); - - if (!filteredVideos.length) { - container.innerHTML = ` -

- No videos available yet. -

`; - return; - } - - const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); - const fragment = document.createDocumentFragment(); - const localAuthorSet = new Set(); - - filteredVideos.forEach((video, index) => { - if (!video.id || !video.title) { - console.error("Missing ID or title:", video); - return; - } - - window.app?.videosMap?.set(video.id, video); - - localAuthorSet.add(video.pubkey); - - const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); - const shareUrl = getAbsoluteShareUrl(nevent); - const canEdit = window.app?.pubkey === video.pubkey; - - const highlightClass = - video.isPrivate && canEdit ? "watch-history-card--private" : ""; - - const timeAgo = window.app?.formatTimeAgo - ? window.app.formatTimeAgo(video.created_at) - : new Date(video.created_at * 1000).toLocaleString(); - - const watchedAtTimestamp = toNumber( - video?.watchHistory?.watchedAt, - null - ); - let watchedAtLabel = "Watch date unavailable"; - if (Number.isFinite(watchedAtTimestamp) && watchedAtTimestamp > 0) { - const watchedDate = new Date(watchedAtTimestamp); - if (!Number.isNaN(watchedDate.getTime())) { - try { - watchedAtLabel = `Watched on ${watchedDate.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - })}`; - } catch (err) { - watchedAtLabel = `Watched on ${watchedDate.toLocaleString()}`; - } - } - } - - const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; - const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; - const playbackUrl = typeof video.url === "string" ? video.url : ""; - const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; - const trimmedMagnet = - typeof video.magnet === "string" ? video.magnet.trim() : ""; - const legacyInfoHash = - typeof video.infoHash === "string" ? video.infoHash.trim() : ""; - const magnetCandidate = trimmedMagnet || legacyInfoHash; - const playbackMagnet = magnetCandidate; - const magnetProvided = magnetCandidate.length > 0; - const watchHistoryKey = video?.watchHistory?.key || ""; - const cardHtml = ` -
-
- -
- ${safeTitle} -
-
-
-

- ${safeTitle} -

-

${timeAgo}

-
-
-
-

${watchedAtLabel}

-
- - -
-
- - View - - - -
-
-
- `; - - const t = document.createElement("template"); - t.innerHTML = cardHtml.trim(); - const cardEl = t.content.firstElementChild; - if (cardEl) { - cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; - if (typeof video.pubkey === "string" && video.pubkey) { - cardEl.dataset.ownerPubkey = video.pubkey; - } else if (cardEl.dataset.ownerPubkey) { - delete cardEl.dataset.ownerPubkey; - } - - const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); - interactiveEls.forEach((el) => { - if (!el.dataset) return; - - if (trimmedUrl) { - el.dataset.playUrl = encodeURIComponent(trimmedUrl); - } else { - delete el.dataset.playUrl; - } - - el.dataset.playMagnet = playbackMagnet || ""; - }); - - if (magnetProvided) { - cardEl.dataset.magnet = playbackMagnet; - } else if (cardEl.dataset.magnet) { - delete cardEl.dataset.magnet; - } - - } - fragment.appendChild(cardEl); - }); - - container.innerHTML = ""; - container.appendChild(fragment); - - window.app?.attachVideoListHandler?.(); - - const lazyEls = container.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); - - const authorPics = container.querySelectorAll(".author-pic"); - const authorNames = container.querySelectorAll(".author-name"); - const creatorButtons = container.querySelectorAll( - ".watch-history-card__creatorAvatar" - ); - - authorPics.forEach((pic) => { - localAuthorSet.add(pic.getAttribute("data-pubkey")); - }); - authorNames.forEach((nameEl) => { - localAuthorSet.add(nameEl.getAttribute("data-pubkey")); - }); - creatorButtons.forEach((btn) => { - localAuthorSet.add(btn.getAttribute("data-pubkey")); - }); - - if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { - window.app.batchFetchProfiles(localAuthorSet); - } - - authorPics.forEach((pic) => { - if (!pic.dataset.watchHistoryClickAttached) { - pic.dataset.watchHistoryClickAttached = "true"; - pic.style.cursor = "pointer"; - pic.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = pic.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); - }); - } - }); - - creatorButtons.forEach((btn) => { - if (!btn.dataset.watchHistoryClickAttached) { - btn.dataset.watchHistoryClickAttached = "true"; - btn.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = btn.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); - }); - } - }); - - authorNames.forEach((nameEl) => { - if (!nameEl.dataset.watchHistoryClickAttached) { - nameEl.dataset.watchHistoryClickAttached = "true"; - nameEl.style.cursor = "pointer"; - nameEl.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = nameEl.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); - }); - } - }); + element.textContent = text; } export function createWatchHistoryRenderer(config = {}) { - const fallbackGetActor = async () => { - const candidate = - typeof window?.app?.pubkey === "string" && window.app.pubkey.trim() - ? window.app.pubkey.trim() - : ""; - return candidate || undefined; - }; - - const mergedConfig = { - viewSelector: "#watchHistoryView", - gridSelector: "#watchHistoryGrid", - loadingSelector: "#watchHistoryLoading", - statusSelector: "#watchHistoryStatus", - emptySelector: "#watchHistoryEmpty", - sentinelSelector: "#watchHistorySentinel", - scrollContainerSelector: null, - emptyCopy: WATCH_HISTORY_EMPTY_COPY, - batchSize: BATCH_SIZE, - getActor: fallbackGetActor, - beforeInitialLoad: null, - getSnapshotFingerprint: null, - resolveBatch: (size) => nostrClient.resolveWatchHistory(size), - renderGrid: null, - ...config, - }; - const { - viewSelector, - gridSelector, - loadingSelector, - statusSelector, - emptySelector, - sentinelSelector, - scrollContainerSelector, - emptyCopy, - batchSize, - getActor, - resolveBatch, - renderGrid, - } = mergedConfig; - - const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; - const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; - - let authChangeListener = null; - let authRefreshPromise = null; - let scheduleAuthRefresh = null; - - const normalizeActorHint = (actorHint) => { - if (typeof actorHint === "string") { - const trimmed = actorHint.trim(); - if (trimmed) { - return trimmed; - } - } - return null; - }; - - const attachAuthChangeListener = () => { - if (authChangeListener || typeof window === "undefined") { - return; - } - - authChangeListener = (event) => { - const detail = event?.detail || {}; - const actorHint = - typeof detail.pubkey === "string" ? detail.pubkey : null; - try { - scheduleAuthRefresh?.(actorHint ?? null); - } catch (error) { - console.warn("[historyView] Failed to schedule auth refresh:", error); - } - }; - - window.addEventListener("bitvid:auth-changed", authChangeListener); - }; - - const detachAuthChangeListener = () => { - if (!authChangeListener || typeof window === "undefined") { - return; - } - - window.removeEventListener("bitvid:auth-changed", authChangeListener); - authChangeListener = null; - }; - - const selectors = { - view: viewSelector, - grid: gridSelector, - loading: loadingSelector, - status: statusSelector, - empty: emptySelector, - sentinel: sentinelSelector, - scrollContainer: scrollContainerSelector, - }; - - const LOG_PREFIX = "[historyView]"; - - const debugLog = (...args) => { - if (typeof console !== "undefined") { - if (typeof console.debug === "function") { - console.debug(LOG_PREFIX, ...args); - return; - } - if (typeof console.log === "function") { - console.log(LOG_PREFIX, ...args); - } - } - }; - - const resolveActor = async (hint = null) => { - if (typeof hint === "string" && hint.trim()) { - return hint.trim(); - } - - if (typeof getActor === "function") { - try { - const candidate = await getActor(); - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); - } - } catch (error) { - debugLog("failed to resolve actor via getActor", error); - } - } - - return null; - }; - - const callBeforeInitialLoad = async (context = {}) => { - let actor = await resolveActor(context.actor ?? null); - const finalContext = { ...context, actor }; - - if (typeof providedBeforeInitialLoad === "function") { - return providedBeforeInitialLoad(finalContext); - } - - const snapshot = await nostrClient.fetchWatchHistory(actor || undefined); - - if (!actor) { - try { - const ensured = await nostrClient.ensureSessionActor(); - if (typeof ensured === "string" && ensured.trim()) { - actor = ensured.trim(); - } - } catch (error) { - debugLog("failed to ensure session actor while resolving history", error); - } - } - - return { actor: actor || null, snapshot }; - }; - - const computeSnapshotFingerprint = async (context = {}) => { - const actor = await resolveActor(context.actor ?? null); - const finalContext = { ...context, actor }; - - if (typeof providedGetSnapshotFingerprint === "function") { - return providedGetSnapshotFingerprint(finalContext); - } - - if (finalContext.refresh) { - await nostrClient.fetchWatchHistory(actor || undefined); - } - - return nostrClient.getWatchHistoryFingerprint(actor || undefined); - }; + viewSelector = "#watchHistoryView", + gridSelector = "#watchHistoryGrid", + loadingSelector = "#watchHistoryLoading", + statusSelector = "#watchHistoryStatus", + emptySelector = "#watchHistoryEmpty", + emptyCopy = WATCH_HISTORY_EMPTY_COPY, + } = config; const state = { - isLoading: false, - hasMore: true, - observer: null, - scrollListener: null, - resolvedVideos: [], initialized: false, - emptyCopy, - batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), + resolvedVideos: [], + hasMore: false, + isLoading: false, actor: null, - snapshotFingerprint: null, - gridEventHandler: null, }; - const applyAccessFilters = () => { - if (!Array.isArray(state.resolvedVideos)) { - state.resolvedVideos = []; - return state.resolvedVideos; - } - - const beforeLength = state.resolvedVideos.length; - const filtered = filterAccessibleVideos(state.resolvedVideos); - - if (filtered.length !== beforeLength) { - debugLog("filtered resolved videos due to block settings", { - before: beforeLength, - after: filtered.length, - }); - } - - state.resolvedVideos = filtered; - return state.resolvedVideos; + let elements = { + view: null, + grid: null, + loading: null, + status: null, + empty: null, }; - const query = (selector) => { - if (!selector) { - return null; - } - try { - return document.querySelector(selector); - } catch (error) { - console.warn("[historyView] Failed to query selector:", selector, error); - return null; - } - }; - - const getElements = () => ({ - view: query(selectors.view), - grid: query(selectors.grid), - loadingEl: query(selectors.loading), - statusEl: query(selectors.status), - emptyEl: query(selectors.empty), - sentinel: query(selectors.sentinel), - scrollContainer: query(selectors.scrollContainer), - }); - - const setStatusMessage = (message = "") => { - const { statusEl } = getElements(); - if (!statusEl) { - return; - } - - const nextMessage = typeof message === "string" ? message.trim() : ""; - if (nextMessage) { - const showSpinner = nextMessage === WATCH_HISTORY_LOADING_STATUS; - statusEl.innerHTML = getSidebarLoadingMarkup(nextMessage, { - showSpinner, - }); - statusEl.classList.remove("hidden"); - } else { - statusEl.textContent = ""; - statusEl.innerHTML = ""; - statusEl.classList.add("hidden"); - } - }; - - const clearStatusMessage = () => { - setStatusMessage(""); - }; - - const setLoadingVisible = (visible, { statusMessage } = {}) => { - const { loadingEl } = getElements(); - if (loadingEl) { - if (visible) { - loadingEl.classList.remove("hidden"); - } else { - loadingEl.classList.add("hidden"); - } - } - - if (visible) { - const message = - typeof statusMessage === "string" && statusMessage.trim() - ? statusMessage - : WATCH_HISTORY_LOADING_STATUS; - setStatusMessage(message); - return; - } - - if (typeof statusMessage === "string") { - setStatusMessage(statusMessage); - } else { - clearStatusMessage(); - } - }; - - const resetUiState = () => { - const { grid, emptyEl } = getElements(); - if (grid) { - grid.innerHTML = ""; - grid.classList.add("hidden"); - } - if (emptyEl) { - emptyEl.textContent = state.emptyCopy; - emptyEl.classList.add("hidden"); - } - setLoadingVisible(true); - }; - - const cleanupObservers = () => { - if (state.observer) { - debugLog("disconnecting intersection observer"); - state.observer.disconnect(); - state.observer = null; - } - if (state.scrollListener?.target && state.scrollListener?.handler) { - debugLog("removing scroll listener"); - state.scrollListener.target.removeEventListener( - "scroll", - state.scrollListener.handler - ); - state.scrollListener = null; - } - }; - - const cleanupGridHandler = () => { - if (!state.gridEventHandler) { - return; - } - const { grid } = getElements(); - if (grid) { - grid.removeEventListener("click", state.gridEventHandler); - } - state.gridEventHandler = null; - }; - - const showEmptyState = (message = state.emptyCopy) => { - const { grid, emptyEl } = getElements(); - if (grid) { - grid.innerHTML = ""; - grid.classList.add("hidden"); - } - if (emptyEl) { - emptyEl.textContent = message; - emptyEl.classList.remove("hidden"); - } - const shouldShowEmptyStatus = message === state.emptyCopy; - const statusMessage = shouldShowEmptyStatus ? WATCH_HISTORY_EMPTY_STATUS : ""; - setLoadingVisible(false, { statusMessage }); - state.hasMore = false; - cleanupObservers(); - debugLog("showing empty state", { message }); - persistSharedUiState(); - }; - - const mergeResolvedVideos = (nextVideos) => { - const ingest = (map, video) => { - if (!video || typeof video !== "object" || !video.id) { - return; - } - - const existing = map.get(video.id); - if (!existing) { - map.set(video.id, video); - return; - } - - const existingScore = getWatchedAtScore(existing); - const nextScore = getWatchedAtScore(video); - - if (nextScore > existingScore) { - map.set(video.id, video); - return; - } - - if (nextScore === existingScore) { - const existingCreated = toNumber( - existing?.created_at, - Number.NEGATIVE_INFINITY - ); - const nextCreated = toNumber( - video?.created_at, - Number.NEGATIVE_INFINITY - ); - - if (nextCreated > existingCreated) { - map.set(video.id, video); - } - } - }; - - if (!Array.isArray(state.resolvedVideos)) { - state.resolvedVideos = []; - } - - if (!Array.isArray(nextVideos) || !nextVideos.length) { - applyAccessFilters(); - return; - } - - const dedupeMap = new Map(); - state.resolvedVideos.forEach((video) => ingest(dedupeMap, video)); - nextVideos.forEach((video) => ingest(dedupeMap, video)); - - state.resolvedVideos = Array.from(dedupeMap.values()).sort( - compareByWatchedAtDesc - ); - applyAccessFilters(); - persistSharedUiState(); - }; - - const ensureGridEventHandlers = () => { - const { grid } = getElements(); - if (!grid || state.gridEventHandler) { - return; - } - - const showRemoveSpinner = (button) => { - if (!button || !button.dataset) { - return; - } - - if (button.dataset.originalHtml === undefined) { - button.dataset.originalHtml = button.innerHTML; - } - - if (button.dataset.originalAriaLabel === undefined) { - button.dataset.originalAriaLabel = button.getAttribute("aria-label") || ""; - } - - button.innerHTML = - ''; - button.setAttribute("aria-label", "Removing from watch history"); - }; - - const restoreRemoveButton = (button) => { - if (!button || !button.dataset) { - return; - } - - const originalHtml = button.dataset.originalHtml; - if (typeof originalHtml === "string") { - button.innerHTML = originalHtml; - } - - const originalAria = button.dataset.originalAriaLabel || ""; - if (originalAria) { - button.setAttribute("aria-label", originalAria); - } else { - button.removeAttribute("aria-label"); - } - - delete button.dataset.originalHtml; - delete button.dataset.originalAriaLabel; - }; - - const handler = async (event) => { - const removeTrigger = event.target.closest( - "[data-history-remove-key]" - ); - if (removeTrigger) { - event.preventDefault(); - event.stopPropagation(); - if (removeTrigger.disabled) { - return; - } - - const targetKey = removeTrigger - .getAttribute("data-history-remove-key") - ?.trim(); - const targetEventId = removeTrigger - .getAttribute("data-history-remove-event-id") - ?.trim(); - if (!targetKey) { - window.app?.showError?.( - "Unable to remove this video from watch history." - ); - return; - } - - removeTrigger.disabled = true; - removeTrigger.setAttribute("aria-busy", "true"); - showRemoveSpinner(removeTrigger); - - try { - const result = await nostrClient.removeWatchHistoryItem(targetKey); - if (!result?.ok) { - const errorMessage = - typeof result?.error === "string" && result.error - ? result.error - : "remove-failed"; - throw new Error(errorMessage); - } - - state.resolvedVideos = state.resolvedVideos.filter((video) => { - const key = video?.watchHistory?.key || ""; - if (key) { - return key !== targetKey; - } - if (targetEventId) { - return video?.id !== targetEventId; - } - return true; - }); - - applyAccessFilters(); - - setLoadingVisible(false); - renderResolvedVideos(); - persistSharedUiState(); - window.app?.showSuccess?.("Removed from watch history."); - } catch (error) { - console.error( - "[historyView] Failed to remove watch history item:", - error - ); - removeTrigger.disabled = false; - window.app?.showError?.( - "Failed to remove this video from watch history." - ); - } finally { - if (removeTrigger.isConnected) { - restoreRemoveButton(removeTrigger); - removeTrigger.removeAttribute("aria-busy"); - } - } - - return; - } - - const shareTrigger = event.target.closest("[data-history-share]"); - if (shareTrigger) { - event.preventDefault(); - event.stopPropagation(); - - const shareUrl = shareTrigger.getAttribute("data-share-url") || ""; - const shareTitle = shareTrigger.getAttribute("data-share-title") || ""; - const eventId = shareTrigger.getAttribute("data-event-id") || ""; - - const normalizedTitle = shareTitle || "Watch on Bitvid"; - - try { - if (shareUrl && navigator.share) { - await navigator.share({ title: normalizedTitle, url: shareUrl }); - return; - } - - if (window.app?.handleMoreMenuAction) { - await window.app.handleMoreMenuAction("copy-link", { eventId }); - return; - } - - if (shareUrl && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(shareUrl); - window.app?.showSuccess?.("Video link copied to clipboard!"); - return; - } - - if (shareUrl) { - window.open(shareUrl, "_blank", "noopener,noreferrer"); - return; - } - - window.app?.showError?.("Unable to share this video right now."); - } catch (error) { - if (error?.name === "AbortError") { - return; - } - console.error( - "[historyView] Failed to share watch history item:", - error - ); - window.app?.showError?.("Unable to share this video right now."); - } - } - }; - - grid.addEventListener("click", handler); - state.gridEventHandler = handler; - }; - - const renderResolvedVideos = () => { - const { grid, emptyEl } = getElements(); - if (!grid) { - return; - } - - const accessibleVideos = applyAccessFilters(); - - if (!accessibleVideos.length) { - if (emptyEl) { - emptyEl.textContent = state.emptyCopy; - emptyEl.classList.remove("hidden"); - } - grid.classList.add("hidden"); - return; - } - - if (typeof renderGrid === "function") { - renderGrid(accessibleVideos, grid); - } else { - renderWatchHistoryGrid(accessibleVideos, grid); - } - - ensureGridEventHandlers(); - - grid.classList.remove("hidden"); - if (emptyEl) { - emptyEl.classList.add("hidden"); - } - }; - - const persistSharedUiState = () => { - if (!state.actor) { - return; - } - - setSharedUiSnapshot(state.actor, { - resolvedVideos: state.resolvedVideos, - hasMore: state.hasMore, - snapshotFingerprint: state.snapshotFingerprint, - }); - }; - - const hydrateFromSharedUiCache = () => { - if (!state.actor) { - return { - hydrated: false, - sharedHasMore: false, - fingerprintMatches: false, - }; - } - - const snapshot = getSharedUiSnapshot(state.actor); - if (!snapshot) { - return { - hydrated: false, - sharedHasMore: false, - fingerprintMatches: false, - }; - } - - state.resolvedVideos = Array.isArray(snapshot.resolvedVideos) - ? snapshot.resolvedVideos - : []; - state.hasMore = snapshot.hasMore !== false; - - applyAccessFilters(); - - const nextFingerprint = - typeof snapshot.snapshotFingerprint === "string" && - snapshot.snapshotFingerprint - ? snapshot.snapshotFingerprint - : null; - - let fingerprintMatches = true; - if ( - typeof state.snapshotFingerprint === "string" && - state.snapshotFingerprint && - nextFingerprint && - nextFingerprint !== state.snapshotFingerprint - ) { - fingerprintMatches = false; - } - - if (!state.snapshotFingerprint && nextFingerprint) { - state.snapshotFingerprint = nextFingerprint; - } - - if (state.resolvedVideos.length) { - setLoadingVisible(false); - renderResolvedVideos(); - } - - return { - hydrated: true, - sharedHasMore: state.hasMore, - fingerprintMatches, + const refreshElements = () => { + elements = { + view: resolveElement(viewSelector), + grid: resolveElement(gridSelector), + loading: resolveElement(loadingSelector), + status: resolveElement(statusSelector), + empty: resolveElement(emptySelector), }; }; - const loadNextBatch = async ({ initial = false } = {}) => { - if (state.isLoading || !state.hasMore) { - debugLog("skipping loadNextBatch", { - reason: state.isLoading ? "already loading" : "no more items", - initial, - resolvedCount: state.resolvedVideos.length, - }); - if (initial && !state.isLoading && state.resolvedVideos.length === 0) { - setLoadingVisible(false); - } - return; - } - - setLoadingVisible(true); - state.isLoading = true; - debugLog("loading next batch", { - initial, - batchSize: state.batchSize, - resolvedCount: state.resolvedVideos.length, - }); - - try { - const videos = await resolveBatch(state.batchSize); - debugLog("resolveBatch completed", { - initial, - received: Array.isArray(videos) ? videos.length : "non-array", - }); - - if (!Array.isArray(videos) || videos.length === 0) { - if (initial && state.resolvedVideos.length === 0) { - showEmptyState(); - } else { - state.hasMore = false; - cleanupObservers(); - setLoadingVisible(false); - debugLog("no videos returned", { - initial, - resolvedCount: state.resolvedVideos.length, - }); - persistSharedUiState(); - } - return; - } - - mergeResolvedVideos(videos); - renderResolvedVideos(); - setLoadingVisible(false); - persistSharedUiState(); - debugLog("rendered videos", { - totalResolved: state.resolvedVideos.length, - initial, - }); - } catch (error) { - console.error("[historyView] Failed to resolve watch history:", error); - if (initial && state.resolvedVideos.length === 0) { - showEmptyState( - "We couldn't load your watch history. Please try again later." - ); - } - } finally { - state.isLoading = false; - debugLog("loadNextBatch finished", { - initial, - hasMore: state.hasMore, - resolvedCount: state.resolvedVideos.length, - }); + const hideLoading = () => { + setHidden(elements.loading, true); + if (elements.status) { + setHidden(elements.status, true); } }; - const attachObservers = () => { - const { sentinel, scrollContainer } = getElements(); - if (!sentinel || !state.hasMore) { - debugLog("skipping observer attachment", { - hasSentinel: Boolean(sentinel), - hasMore: state.hasMore, - }); - return; + const showEmpty = () => { + if (elements.grid) { + elements.grid.innerHTML = ""; + setHidden(elements.grid, true); } - - if ("IntersectionObserver" in window) { - debugLog("attaching intersection observer", { - useScrollContainer: Boolean(scrollContainer), - }); - state.observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - debugLog("sentinel intersected"); - loadNextBatch(); - } - }); - }, - { - root: scrollContainer || null, - rootMargin: scrollContainer ? "200px 0px" : "300px 0px", - } - ); - state.observer.observe(sentinel); - return; + if (elements.empty) { + setTextContent(elements.empty, emptyCopy); + setHidden(elements.empty, false); } - - const target = scrollContainer || window; - const handler = () => { - const rect = sentinel.getBoundingClientRect(); - const threshold = scrollContainer - ? scrollContainer.clientHeight - : window.innerHeight; - if (rect.top <= threshold + 200) { - debugLog("scroll threshold met"); - loadNextBatch(); - } - }; - - target.addEventListener("scroll", handler, { passive: true }); - state.scrollListener = { target, handler }; - handler(); - debugLog("attached scroll listener", { - useScrollContainer: Boolean(scrollContainer), - }); }; - scheduleAuthRefresh = (actorHint) => { - const normalizedActor = normalizeActorHint(actorHint); - - const perform = async () => { - const nextActor = normalizedActor || null; - - try { - const { view } = getElements(); - if (!view) { - state.actor = nextActor; - state.snapshotFingerprint = null; - state.initialized = false; - state.resolvedVideos = []; - state.hasMore = true; - state.isLoading = false; - cleanupObservers(); - return; - } - - await runInitialLoad({ actor: nextActor }); - } catch (error) { - console.error( - "[historyView] Failed to refresh watch history after auth change:", - error - ); - } - }; - - const pendingRefresh = - authRefreshPromise !== null - ? authRefreshPromise.then(perform, perform) - : perform(); - - let currentPromise; - const finalize = () => { - if (authRefreshPromise === currentPromise) { - authRefreshPromise = null; - } - }; - - currentPromise = pendingRefresh.finally(finalize); - authRefreshPromise = currentPromise; - return currentPromise; + const resetUi = () => { + refreshElements(); + hideLoading(); + showEmpty(); }; - const runInitialLoad = async (options = {}) => { - const { actor: actorHint = null, prefetched = null, fingerprintOverride } = - typeof options === "object" && options !== null ? options : {}; - - const { view } = getElements(); - if (!view) { - debugLog("runInitialLoad aborted: view not found"); - return; - } - - const resolvedActor = await resolveActor(actorHint ?? state.actor); - state.actor = resolvedActor || null; - - cleanupObservers(); - state.resolvedVideos = []; - state.hasMore = true; - state.isLoading = false; - state.initialized = false; - state.snapshotFingerprint = null; - resetUiState(); - debugLog("starting initial load", { actor: state.actor }); - - let beforeResult; - const hasPrefetched = - prefetched && - Object.prototype.hasOwnProperty.call(prefetched, "beforeResult"); - - if (hasPrefetched) { - beforeResult = prefetched.beforeResult; - } else { - try { - beforeResult = await callBeforeInitialLoad({ actor: state.actor }); - } catch (error) { - console.error("[historyView] Failed to fetch watch history list:", error); - showEmptyState( - "We couldn't load your watch history. Please try again later." - ); - return; - } - } - - if ( - beforeResult && - typeof beforeResult === "object" && - typeof beforeResult.actor === "string" && - beforeResult.actor.trim() - ) { - state.actor = beforeResult.actor.trim(); - } - - if (state.actor) { - nostrClient.resetWatchHistoryProgress(state.actor); - } - - let fingerprint; - if (typeof fingerprintOverride !== "undefined") { - fingerprint = fingerprintOverride; - } else { - try { - fingerprint = await computeSnapshotFingerprint({ - actor: state.actor, - beforeResult, - refresh: false, - }); - } catch (error) { - debugLog("failed to compute snapshot fingerprint", error); - } - } - - if (typeof fingerprint === "string") { - state.snapshotFingerprint = fingerprint; - } else { - state.snapshotFingerprint = null; - } - - let sharedHydration = null; - try { - sharedHydration = hydrateFromSharedUiCache(); - } catch (error) { - debugLog("failed to hydrate shared watch history state", error); - } - - if (sharedHydration?.hydrated) { - state.hasMore = true; - } - - try { - await accessControl.ensureReady(); - } catch (error) { - console.warn( - "[historyView] Access control not ready, proceeding with cached lists:", - error - ); - } - - await loadNextBatch({ initial: true }); - - if (state.hasMore) { - attachObservers(); - } - - state.initialized = true; - debugLog("initial load complete", { - hasMore: state.hasMore, - resolvedCount: state.resolvedVideos.length, - actor: state.actor, - }); - }; - - attachAuthChangeListener(); - return { async init() { - debugLog("init called"); - await runInitialLoad(); + resetUi(); + state.initialized = true; + state.resolvedVideos = []; + state.hasMore = false; + state.isLoading = false; }, async ensureInitialLoad() { - const { view } = getElements(); - if (!view) { - debugLog("ensureInitialLoad aborted: view not found"); - return; - } - if (!state.initialized) { - debugLog("ensureInitialLoad rerunning initial load (not initialized)"); - await runInitialLoad(); + await this.init(); return; } - - state.actor = (await resolveActor(state.actor)) || null; - - if (!state.isLoading) { - try { - const beforeResult = await callBeforeInitialLoad({ - actor: state.actor, - reason: "ensureInitialLoad", - }); - if ( - beforeResult && - typeof beforeResult === "object" && - typeof beforeResult.actor === "string" && - beforeResult.actor.trim() - ) { - state.actor = beforeResult.actor.trim(); - } - const fingerprint = await computeSnapshotFingerprint({ - actor: state.actor, - beforeResult, - refresh: false, - }); - - if (typeof fingerprint === "string") { - if (fingerprint !== state.snapshotFingerprint) { - debugLog("ensureInitialLoad detected new snapshot", { - previous: state.snapshotFingerprint, - next: fingerprint, - actor: state.actor, - }); - await runInitialLoad({ - actor: state.actor, - prefetched: { beforeResult }, - fingerprintOverride: fingerprint, - }); - return; - } - state.snapshotFingerprint = fingerprint; - } - } catch (error) { - console.warn( - "[historyView] Failed to refresh watch history snapshot:", - error - ); - } - } else { - debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", { - actor: state.actor, - }); - } - - if (!state.resolvedVideos.length && !state.isLoading) { - if (!state.hasMore) { - debugLog("ensureInitialLoad resetting hasMore before retry"); - state.hasMore = true; - } - debugLog("ensureInitialLoad requesting new batch", { - hasMore: state.hasMore, - }); - await loadNextBatch({ initial: true }); - - if (state.hasMore && !state.observer && !state.scrollListener) { - debugLog("ensureInitialLoad reattaching observers"); - attachObservers(); - } - } else { - debugLog("ensureInitialLoad no action", { - resolvedCount: state.resolvedVideos.length, - isLoading: state.isLoading, - hasMore: state.hasMore, - }); - } + resetUi(); }, async loadMore() { - debugLog("loadMore called"); - await loadNextBatch(); - }, - resume() { - cleanupObservers(); - if (state.hasMore) { - debugLog("resume attaching observers"); - attachObservers(); - } - }, - pause() { - debugLog("pause called"); - cleanupObservers(); + return []; }, + resume() {}, + pause() {}, destroy() { - debugLog("destroy called"); - cleanupObservers(); - cleanupGridHandler(); - detachAuthChangeListener(); state.initialized = false; state.resolvedVideos = []; - state.hasMore = true; + state.hasMore = false; state.isLoading = false; - state.actor = null; - state.snapshotFingerprint = null; - resetUiState(); - setLoadingVisible(false); + hideLoading(); + showEmpty(); }, - async refresh(actorHint = null) { - await scheduleAuthRefresh(actorHint); + async refresh() { + await this.ensureInitialLoad(); }, render() { - renderResolvedVideos(); + resetUi(); }, getState() { return { diff --git a/js/index.js b/js/index.js index dfb19a99..7f623e7b 100644 --- a/js/index.js +++ b/js/index.js @@ -2,45 +2,6 @@ import "./bufferPolyfill.js"; import { trackPageView } from "./analytics.js"; -import { nostrClient } from "./nostr.js"; - -const HISTORY_PREFETCH_DEBOUNCE_MS = 250; - -function prefetchWatchHistory(options = {}) { - if (typeof nostrClient?.fetchWatchHistory !== "function") { - return; - } - - const force = options.force === true; - const now = Date.now(); - - if (typeof window !== "undefined") { - const last = - typeof window.__bitvidLastHistoryPrefetch === "number" - ? window.__bitvidLastHistoryPrefetch - : 0; - - if (!force && now - last < HISTORY_PREFETCH_DEBOUNCE_MS) { - return; - } - - window.__bitvidLastHistoryPrefetch = now; - } - - let actor; - if (typeof window?.app?.pubkey === "string" && window.app.pubkey.trim()) { - actor = window.app.pubkey.trim(); - } - - void nostrClient - .fetchWatchHistory(actor, { forceRefresh: true }) - .catch((error) => { - console.warn( - "[index] Failed to prefetch watch history during navigation:", - error - ); - }); -} const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in"; const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in"; @@ -298,7 +259,6 @@ export function setHashView(viewName) { window.history.replaceState({}, "", newUrl); if (typeof viewName === "string" && viewName.toLowerCase() === "history") { - prefetchWatchHistory({ force: true }); } handleHashChange(); @@ -448,7 +408,6 @@ async function handleHashChange() { const viewName = match[1]; // only the chunk before any '&' if (typeof viewName === "string" && viewName.toLowerCase() === "history") { - prefetchWatchHistory(); } const viewUrl = `views/${viewName}.html`; diff --git a/js/nostr.js b/js/nostr.js index 548eb0e1..328a40e6 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2,14 +2,6 @@ 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"; @@ -25,8 +17,8 @@ import { buildVideoPostEvent, buildVideoMirrorEvent, buildViewEvent, - buildWatchHistoryIndexEvent, - buildWatchHistoryChunkEvent, + getNostrEventSchema, + NOTE_TYPES, } from "./nostrEventSchemas.js"; import { publishEventToRelay, @@ -55,100 +47,15 @@ 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(); +const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT); +const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind) + ? VIEW_EVENT_SCHEMA.kind + : 30079; + let ingestLocalViewEventRef = null; async function loadIngestLocalViewEvent() { @@ -172,128 +79,6 @@ async function loadIngestLocalViewEvent() { 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; @@ -608,7 +393,7 @@ function createVideoViewEventFilters(pointer) { } const pointerFilter = { - kinds: WATCH_HISTORY_READ_KINDS, + kinds: [VIEW_EVENT_KIND], "#t": ["view"], }; @@ -622,7 +407,7 @@ function createVideoViewEventFilters(pointer) { if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) { filters.push({ - kinds: WATCH_HISTORY_READ_KINDS, + kinds: [VIEW_EVENT_KIND], "#t": ["view"], "#video": [resolved.value], }); @@ -809,7 +594,7 @@ function isVideoViewEvent(event, pointer) { return false; } - if (!isWatchHistoryKind(event.kind)) { + if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) { return false; } @@ -1489,7 +1274,7 @@ class NostrClient { this.watchHistoryStorage = null; this.watchHistoryRepublishTimers = new Map(); this.watchHistoryRefreshPromises = new Map(); - this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; + this.watchHistoryCacheTtlMs = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); } @@ -1637,1283 +1422,44 @@ class NostrClient { } /** - * Resolve the effective TTL for watch-history cache entries. + * Watch history has been removed from the client. All related helpers now + * return disabled responses so callers can gracefully no-op without touching + * storage or relays. */ getWatchHistoryCacheTtlMs() { - const candidate = this.watchHistoryCacheTtlMs; - if (Number.isFinite(candidate) && candidate >= 0) { - return candidate; - } - return WATCH_HISTORY_CACHE_TTL_MS; + return 0; } - /** - * 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; + return { version: 1, actors: {} }; } - createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) { - const normalizedItems = []; - const seen = new Set(); + persistWatchHistoryEntry() {} - 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 }); - } - } + cancelWatchHistoryRepublish() {} - 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(), - }; + scheduleWatchHistoryRepublish() {} - 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); - } - } + ensureWatchHistoryBackgroundRefresh() {} - 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; + async publishWatchHistorySnapshot() { + return { ok: false, error: "watch-history-disabled" }; } - 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); - } - } + async updateWatchHistoryList() { + return { ok: false, error: "watch-history-disabled" }; } - 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}`; + async removeWatchHistoryItem() { + return { ok: false, error: "watch-history-disabled" }; } - 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 fetchWatchHistory() { + return { pointerEvent: null, items: [] }; } - 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 resolveWatchHistory() { + return []; } async publishViewEvent(videoPointer, options = {}) { @@ -3018,8 +1564,6 @@ class NostrClient { } } - const dedupeTag = generateViewEventDedupeTag(actorPubkey, pointer, createdAt); - const event = buildViewEvent({ pubkey: actorPubkey, created_at: createdAt, @@ -3027,7 +1571,6 @@ class NostrClient { pointerTag, includeSessionTag: usingSessionActor, additionalTags, - dedupeTag, content, }); @@ -3097,1883 +1640,6 @@ class NostrClient { }; } - 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) { diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 3b87f52f..ac78e4a4 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -530,7 +530,6 @@ async function testRecordVideoViewEmitsJsonPayload() { const originalEnsureSessionActor = nostrClient.ensureSessionActor; const originalSessionActor = nostrClient.sessionActor; const originalPubkey = nostrClient.pubkey; - const originalUpdateHistory = nostrClient.updateWatchHistoryList; const originalWarn = console.warn; const originalNostrTools = window.NostrTools; @@ -582,11 +581,6 @@ async function testRecordVideoViewEmitsJsonPayload() { }; nostrClient.pubkey = ""; - nostrClient.updateWatchHistoryList = async () => { - throw new Error( - "recordVideoView should not invoke updateWatchHistoryList during view publishes" - ); - }; try { const result = await nostrClient.recordVideoView(pointer, { @@ -633,7 +627,6 @@ async function testRecordVideoViewEmitsJsonPayload() { nostrClient.ensureSessionActor = originalEnsureSessionActor; nostrClient.sessionActor = originalSessionActor; nostrClient.pubkey = originalPubkey; - nostrClient.updateWatchHistoryList = originalUpdateHistory; console.warn = originalWarn; window.NostrTools = originalNostrTools; } diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs deleted file mode 100644 index 8d95e0d5..00000000 --- a/tests/watch-history.test.mjs +++ /dev/null @@ -1,1662 +0,0 @@ -import "./test-helpers/setup-localstorage.mjs"; -import assert from "node:assert/strict"; - -const { - WATCH_HISTORY_LIST_IDENTIFIER, - WATCH_HISTORY_KIND, - WATCH_HISTORY_FETCH_EVENT_LIMIT, -} = await import("../js/config.js"); -const { - NOTE_TYPES, - getNostrEventSchema, - setNostrEventSchemaOverrides, - buildWatchHistoryIndexEvent, - buildWatchHistoryChunkEvent, -} = await import("../js/nostrEventSchemas.js"); -const { compareByWatchedAtDesc, getWatchedAtScore } = await import( - "../js/watchHistorySort.js" -); - -if (typeof globalThis.window === "undefined") { - globalThis.window = {}; -} - -if (!globalThis.window.NostrTools) { - globalThis.window.NostrTools = {}; -} - -globalThis.window.NostrTools.getEventHash = (event) => { - const payload = `${event.kind}:${event.pubkey}:${event.created_at}`; - return `hash-${Buffer.from(payload).toString("hex")}`; -}; - -globalThis.window.NostrTools.signEvent = (event, privateKey) => - `sig-${privateKey.slice(0, 8)}-${event.created_at}`; - -delete globalThis.window.NostrTools.nip04; -delete globalThis.window.nostr; - -const { nostrClient } = await import("../js/nostr.js"); - -function createPublishingClient(actorPubkey, options = {}) { - const client = new nostrClient.constructor(); - const publishedEvents = []; - const payloads = []; - const scheduledRepublishes = []; - const cancelledRepublishes = []; - const persistedEntries = []; - const onPublish = - typeof options.onPublish === "function" ? options.onPublish : null; - - client.pool = { - publish(urls, event) { - publishedEvents.push(event); - const publishDecision = onPublish ? onPublish(event) : null; - const shouldSucceed = publishDecision?.ok !== false; - return { - on(eventName, handler) { - setTimeout(() => { - if (shouldSucceed) { - if (eventName === "ok" || eventName === "seen") { - handler(); - } - } else if (eventName === "failed") { - const providedError = publishDecision?.error; - const error = - providedError instanceof Error - ? providedError - : new Error( - providedError - ? String(providedError) - : "mock publish failure" - ); - handler(error); - } - }, 0); - }, - }; - }, - list: async () => [], - }; - client.relays = ["wss://unit.test"]; - client.pubkey = ""; - client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; - client.ensureSessionActor = async () => actorPubkey; - client.encryptWatchHistoryPayload = async (_actor, payload) => { - payloads.push(payload); - return { ok: true, ciphertext: JSON.stringify(payload) }; - }; - client.persistWatchHistoryEntry = (_actor, entry) => { - persistedEntries.push(entry); - }; - client.cancelWatchHistoryRepublish = (actor) => { - cancelledRepublishes.push(actor); - }; - client.scheduleWatchHistoryRepublish = (actor, items) => { - scheduledRepublishes.push({ actor, items }); - }; - return { - client, - publishedEvents, - payloads, - scheduledRepublishes, - cancelledRepublishes, - persistedEntries, - }; -} - -function createDecryptClient(actorPubkey) { - const client = new nostrClient.constructor(); - client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; - client.ensureSessionActor = async () => actorPubkey; - return client; -} - -const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const LEGACY_WATCH_HISTORY_KIND = 30078; -function deriveChunkPrefix(identifier) { - if (typeof identifier !== "string") { - return "watch-history:v2"; - } - - const trimmed = identifier.trim().replace(/\/+$/, ""); - if (!trimmed) { - return "watch-history:v2"; - } - - 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`; -} - -const WATCH_HISTORY_CHUNK_PREFIX = deriveChunkPrefix( - WATCH_HISTORY_LIST_IDENTIFIER -); - -const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX); -assert(indexSchema, "index schema should be available"); - -const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); -assert(chunkSchema, "chunk schema should be available"); - -const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`; -const sampleChunkAddresses = [ - `${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`, - `${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`, -]; - -const sampleIndexEvent = buildWatchHistoryIndexEvent({ - pubkey: ACTOR, - created_at: 111, - snapshotId: "sample", - totalChunks: 2, - chunkAddresses: sampleChunkAddresses, -}); - -const sampleIndexIdentifierTag = sampleIndexEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" -); -assert(sampleIndexIdentifierTag, "index event should include identifier tag"); -assert.equal( - sampleIndexIdentifierTag[1], - WATCH_HISTORY_LIST_IDENTIFIER, - "index event should use canonical watch history identifier" -); - -const sampleChunksTag = sampleIndexEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "chunks" -); -assert(sampleChunksTag, "index event should include chunks tag"); -assert.equal( - Number.parseInt(sampleChunksTag[1], 10), - 2, - "chunks tag should reflect total chunks" -); - -const sampleIndexAddresses = sampleIndexEvent.tags - .filter((tag) => Array.isArray(tag) && tag[0] === "a") - .map((tag) => tag[1]); -assert.deepEqual( - sampleIndexAddresses, - sampleChunkAddresses, - "index event should list chunk addresses" -); - -const sampleChunkEvent = buildWatchHistoryChunkEvent({ - pubkey: ACTOR, - created_at: 222, - chunkIdentifier: sampleChunkIdentifier, - snapshotId: "sample", - chunkIndex: 0, - totalChunks: 2, - pointerTags: [["e", "sample-event"]], - content: "ciphertext", -}); - -const chunkIdentifierTag = sampleChunkEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" -); -assert(chunkIdentifierTag, "chunk event should include identifier tag"); -assert.equal( - chunkIdentifierTag[1], - sampleChunkIdentifier, - "chunk event should use provided identifier" -); -assert( - sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"), - "chunk event should include snapshot tag" -); -assert( - sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"), - "chunk event should include chunk tag" -); -assert( - sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"), - "chunk event should include encrypted tag" -); - -setNostrEventSchemaOverrides({ - [NOTE_TYPES.WATCH_HISTORY_INDEX]: { - identifierTag: { name: "d", value: "custom:index" }, - }, - [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { - identifierTag: { name: "d", value: "custom:chunk" }, - }, -}); - -const overriddenIndexEvent = buildWatchHistoryIndexEvent({ - pubkey: ACTOR, - created_at: 333, - snapshotId: "override", - totalChunks: 1, -}); -assert( - overriddenIndexEvent.tags.some( - (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index" - ), - "index builder should honor overrides" -); - -const overriddenChunkEvent = buildWatchHistoryChunkEvent({ - pubkey: ACTOR, - created_at: 444, - snapshotId: "override", - chunkIndex: 0, - totalChunks: 1, - pointerTags: [], - content: "cipher", -}); -assert( - overriddenChunkEvent.tags.some( - (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk" - ), - "chunk builder should honor identifier override" -); - -setNostrEventSchemaOverrides({}); - -const { client: publishingClient, publishedEvents, payloads } = - createPublishingClient(ACTOR); - -publishingClient.watchHistoryPayloadMaxBytes = 400; -publishingClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; - -const baseWatchedAt = 1_700_000_000_000; -const longPointers = Array.from({ length: 8 }, (_, index) => { - const relay = index % 3 === 0 ? "wss://relay.unit" : null; - const watchedAt = baseWatchedAt + index * 60_000; - if (index % 2 === 0) { - return { - type: "e", - value: `event-${index}-${"x".repeat(90)}`, - relay, - watchedAt, - }; - } - return { - type: "a", - value: `${WATCH_HISTORY_KIND}:${ACTOR}:history-${index}-${"y".repeat(60)}`, - relay, - watchedAt, - }; -}); - -const publishResult = await publishingClient.publishWatchHistorySnapshot( - ACTOR, - longPointers -); - -assert.equal(publishResult.ok, true, "publish should report success"); -assert(publishResult.events.length > 1, "snapshot should include index and chunk events"); -assert.equal( - publishResult.events.length, - payloads.length + 1, - "index event should accompany each chunk payload" -); - -payloads.forEach((payload) => { - const size = JSON.stringify(payload).length; - assert( - size <= publishingClient.watchHistoryPayloadMaxBytes, - `payload size ${size} should respect cap` - ); - assert.equal(payload.version, 2, "chunk payload should use version 2"); -}); - -const snapshotIds = new Set( - publishResult.events.map((event) => - event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1] - ) -); -assert.equal(snapshotIds.size, 1, "all events should share a snapshot id"); - -const indexEvent = publishResult.indexEvent; -assert(indexEvent, "publish result should include index event"); - -const indexIdentifierTag = indexEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" -); -assert(indexIdentifierTag, "index event should include identifier tag"); -assert.equal( - indexIdentifierTag[1], - WATCH_HISTORY_LIST_IDENTIFIER, - "index event should use canonical identifier" -); - -const indexChunksTag = indexEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "chunks" -); -assert(indexChunksTag, "index event should include chunks tag"); -assert.equal( - Number.parseInt(indexChunksTag[1], 10), - publishResult.chunks.length, - "index event should report chunk count" -); - -const chunkEvents = publishResult.chunks.map((chunk) => chunk.event); -const chunkIdentifiers = chunkEvents.map((event) => - event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] -); - -assert.equal( - chunkIdentifiers.length, - chunkEvents.length, - "each chunk should expose a d tag" -); - -const uniqueChunkIdentifiers = new Set(chunkIdentifiers); -assert.equal( - uniqueChunkIdentifiers.size, - chunkEvents.length, - "each chunk should have a unique d tag" -); - -chunkIdentifiers.forEach((identifier, index) => { - assert(identifier, `chunk ${index} should include identifier`); - assert( - identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX), - `chunk ${index} should use the chunk prefix` - ); - assert.notEqual( - identifier, - WATCH_HISTORY_LIST_IDENTIFIER, - "chunk identifiers should differ from index identifier" - ); -}); - -const indexAddresses = indexEvent.tags - .filter((tag) => Array.isArray(tag) && tag[0] === "a") - .map((tag) => tag[1]); - -const expectedChunkAddresses = chunkEvents.map((event) => { - const identifier = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" - )?.[1]; - assert(identifier, "chunk should include identifier tag"); - return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`; -}); - -expectedChunkAddresses.forEach((address) => { - assert( - indexAddresses.includes(address), - `index event should reference ${address}` - ); -}); - -const CHUNK_FAILURE_ACTOR = `${ACTOR}-chunk-failure`; -const { - client: chunkFailureClient, - publishedEvents: chunkFailureEvents, - payloads: chunkFailurePayloads, - scheduledRepublishes: chunkFailureRepublishes, - cancelledRepublishes: chunkFailureCancels, -} = createPublishingClient(CHUNK_FAILURE_ACTOR, { - onPublish(event) { - const chunkTag = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "chunk" - ); - if (chunkTag) { - const chunkIndex = Number.parseInt(chunkTag[1], 10); - const total = Number.parseInt(chunkTag[2], 10); - if ( - Number.isInteger(chunkIndex) && - Number.isInteger(total) && - (total > 1 ? chunkIndex === total - 1 : chunkIndex === 0) - ) { - return { ok: false, error: new Error("chunk publish failed") }; - } - } - return { ok: true }; - }, -}); - -chunkFailureClient.watchHistoryPayloadMaxBytes = - publishingClient.watchHistoryPayloadMaxBytes; -chunkFailureClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; - -const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot( - CHUNK_FAILURE_ACTOR, - longPointers -); - -assert.equal( - chunkFailureResult.ok, - false, - "chunk failure should report overall failure" -); -assert.equal( - chunkFailureResult.indexEvent, - null, - "chunk failure should omit index event" -); -assert.equal( - chunkFailureResult.events.length, - chunkFailureResult.chunks.length, - "chunk failure should not include index event in combined events" -); -assert.equal( - chunkFailureResult.indexResults.length, - 0, - "chunk failure should skip index publish attempts" -); -assert( - chunkFailureResult.chunks.some((chunk) => chunk.success === false), - "chunk failure results should flag the failed chunk" -); -assert.equal( - chunkFailureRepublishes.length, - 1, - "chunk failure should schedule a retry" -); -assert.equal( - chunkFailureCancels.length, - 0, - "chunk failure should not cancel retries" -); -assert.equal( - chunkFailureRepublishes[0].actor, - CHUNK_FAILURE_ACTOR, - "chunk failure retry should target the affected actor" -); -assert( - chunkFailureEvents.every((event) => - event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") - ), - "chunk failure should avoid publishing index events" -); -assert.equal( - chunkFailurePayloads.length, - chunkFailureResult.chunks.length, - "chunk failure should still prepare payloads for each chunk" -); - -const chunkFailureCache = chunkFailureClient.watchHistoryCache.get( - CHUNK_FAILURE_ACTOR -); - -assert(chunkFailureCache, "chunk failure should cache a watch history entry"); -assert.equal( - chunkFailureCache.chunkPointers.index, - null, - "chunk failure cache should not retain an index pointer" -); -assert( - Array.from(chunkFailureCache.chunkEvents.values()).every((event) => - event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") - ), - "chunk failure cache should only persist chunk events" -); -assert.deepEqual( - chunkFailureRepublishes[0].items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - chunkFailureResult.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - "republish scheduler should receive the remaining items" -); - -const LOGGED_ACTOR = `${ACTOR}-logged`; -const { - client: noSignerClient, - publishedEvents: noSignerPublishedEvents, - scheduledRepublishes: noSignerRepublishes, - persistedEntries: noSignerPersistedEntries, -} = createPublishingClient(LOGGED_ACTOR); - -noSignerClient.pubkey = LOGGED_ACTOR; - -const noSignerItems = [ - { - type: "e", - value: `${LOGGED_ACTOR}-event`, - watchedAt: baseWatchedAt + 123, - }, - { - type: "a", - value: `${WATCH_HISTORY_KIND}:${LOGGED_ACTOR}:history`, - watchedAt: baseWatchedAt + 456, - }, -]; - -const noSignerResult = await noSignerClient.publishWatchHistorySnapshot( - LOGGED_ACTOR, - noSignerItems -); - -assert.equal( - noSignerResult.ok, - false, - "logged actor without signer should report failure" -); -assert.equal( - noSignerResult.error, - "signing-unavailable", - "missing signer failure should flag availability" -); -assert.equal( - noSignerPublishedEvents.length, - 0, - "no events should publish when signer unavailable" -); -assert.equal( - noSignerRepublishes.length, - 1, - "missing signer should schedule a retry" -); -assert.equal( - noSignerRepublishes[0].actor, - LOGGED_ACTOR, - "retry should target the logged actor" -); - -const cachedLoggedEntry = noSignerClient.watchHistoryCache.get(LOGGED_ACTOR); -assert(cachedLoggedEntry, "missing signer should keep snapshot cached"); -assert.equal( - cachedLoggedEntry.items.length, - noSignerItems.length, - "cached entry should retain merged items" -); -assert.equal( - noSignerPersistedEntries.length, - 1, - "missing signer should persist cached payload" -); -assert.deepEqual( - cachedLoggedEntry.items.map((item) => ({ - type: item.type, - value: item.value, - })), - noSignerResult.items.map((item) => ({ - type: item.type, - value: item.value, - })), - "returned items should mirror cached payload" -); - -const scheduledLoggedKeys = new Set( - noSignerRepublishes[0].items.map((item) => `${item.type}:${item.value}`) -); -const cachedLoggedKeys = new Set( - cachedLoggedEntry.items.map((item) => `${item.type}:${item.value}`) -); -assert.deepEqual( - scheduledLoggedKeys, - cachedLoggedKeys, - "scheduled retry should include cached items" -); - -const NO_SEEN_ACTOR = `${ACTOR}-no-seen`; -const { - client: noSeenClient, - publishedEvents: noSeenPublishedEvents, -} = createPublishingClient(NO_SEEN_ACTOR); - -noSeenClient.pool.publish = (_urls, event) => { - noSeenPublishedEvents.push(event); - return { - on(eventName, handler) { - if (eventName === "seen") { - throw new TypeError("seen unsupported"); - } - if (eventName === "ok") { - setTimeout(handler, 0); - } else if (eventName === "failed") { - // Never invoke failed handlers in this mock; they should remain idle. - } - }, - }; -}; - -const noSeenResult = await noSeenClient.publishWatchHistorySnapshot( - NO_SEEN_ACTOR, - [{ type: "e", value: "no-seen-pointer", relay: null }] -); - -assert.equal( - noSeenResult.ok, - true, - "publish should succeed when relay handle rejects seen handlers" -); -assert( - noSeenResult.events.length > 0, - "publish should still return the emitted events when seen is unsupported" -); - -delete globalThis.window.nostr; - -if (!globalThis.window.NostrTools.nip04) { - globalThis.window.NostrTools.nip04 = {}; -} - -globalThis.window.NostrTools.nip04.decrypt = async ( - _priv, - _pub, - ciphertext -) => ciphertext; - -const decryptClient = createDecryptClient(ACTOR); -decryptClient.relays = ["wss://unit.test"]; -decryptClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; -decryptClient.pool = { - list: async () => publishedEvents, -}; - -const fetched = await decryptClient.fetchWatchHistory(ACTOR); - -assert.equal( - fetched.items.length, - longPointers.length, - "fetch should reassemble all chunked items" -); - -assert.deepEqual( - fetched.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - longPointers.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - "chunked fetch should preserve pointer order and timestamps" -); - -if (typeof localStorage !== "undefined") { - localStorage.clear(); -} - -const headEvent = publishResult.event; - -const legacyPointerIdentifiers = new Set(); -const legacyIndexEvent = JSON.parse(JSON.stringify(headEvent)); -legacyIndexEvent.kind = LEGACY_WATCH_HISTORY_KIND; -legacyIndexEvent.tags = legacyIndexEvent.tags.map((tag) => { - if ( - Array.isArray(tag) && - tag[0] === "a" && - typeof tag[1] === "string" && - !legacyPointerIdentifiers.size - ) { - const [kindStr, pubkey, ...identifierParts] = tag[1].split(":"); - if (kindStr === String(WATCH_HISTORY_KIND) && identifierParts.length) { - const identifier = identifierParts.join(":"); - legacyPointerIdentifiers.add(identifier); - const legacyAddress = `${LEGACY_WATCH_HISTORY_KIND}:${pubkey}:${identifier}`; - const clone = tag.slice(); - clone[1] = legacyAddress; - return clone; - } - } - return tag; -}); -const legacyChunkIdentifier = legacyPointerIdentifiers.size - ? Array.from(legacyPointerIdentifiers)[0] - : null; - -const snapshotId = headEvent.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "snapshot" -)?.[1]; - -const freshClient = createDecryptClient(ACTOR); -freshClient.relays = ["wss://unit.test"]; -freshClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; - -const observedFilters = []; -freshClient.pool = { - list: async (_relays, filters) => { - observedFilters.push(filters); - - const firstFilter = filters[0] || {}; - const dFilter = Array.isArray(firstFilter["#d"]) - ? firstFilter["#d"] - : []; - - if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { - return [legacyIndexEvent]; - } - - const requestedIdentifiers = new Set(); - const requestedSnapshots = new Set(); - - for (const filter of filters) { - if (Array.isArray(filter?.["#d"])) { - for (const identifier of filter["#d"]) { - requestedIdentifiers.add(identifier); - } - } - if (Array.isArray(filter?.["#snapshot"])) { - for (const value of filter["#snapshot"]) { - requestedSnapshots.add(value); - } - } - } - - const responses = []; - - for (const event of publishResult.events) { - if (event.id === headEvent.id) { - continue; - } - const cloned = JSON.parse(JSON.stringify(event)); - if (legacyChunkIdentifier) { - const identifierTag = cloned.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" - ); - if (identifierTag?.[1] === legacyChunkIdentifier) { - cloned.kind = LEGACY_WATCH_HISTORY_KIND; - } - } - const identifier = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" - )?.[1]; - const eventSnapshot = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "snapshot" - )?.[1]; - - if (identifier && requestedIdentifiers.has(identifier)) { - responses.push(cloned); - continue; - } - if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) { - responses.push(cloned); - } - } - - return responses; - }, -}; - -const rebuilt = await freshClient.fetchWatchHistory(ACTOR); - -assert.equal( - rebuilt.items.length, - longPointers.length, - "fresh client should rebuild full watch history list" -); - -assert.deepEqual( - rebuilt.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - longPointers.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - watchedAt: item.watchedAt, - })), - "rebuilt snapshot should preserve timestamps" -); - -assert( - observedFilters.some((filters) => - filters.some((filter) => { - const kinds = Array.isArray(filter?.kinds) ? filter.kinds : []; - return ( - kinds.includes(WATCH_HISTORY_KIND) && - kinds.includes(LEGACY_WATCH_HISTORY_KIND) - ); - }) - ), - "fetchWatchHistory should request both new and legacy kinds during migration" -); - -assert( - observedFilters.length >= 2, - "fetch should include a follow-up query for chunk events" -); - -const followUpFilters = observedFilters[observedFilters.length - 1]; -const identifiersRequested = new Set(); -let snapshotRequested = false; - -for (const filter of followUpFilters) { - if (Array.isArray(filter?.["#d"])) { - for (const identifier of filter["#d"]) { - identifiersRequested.add(identifier); - } - } - if (Array.isArray(filter?.["#snapshot"])) { - snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId); - } -} - -assert( - chunkEvents.every((event) => { - const identifier = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" - )?.[1]; - return ( - identifiersRequested.has(identifier) || - (snapshotId && snapshotRequested) - ); - }), - "follow-up query should target chunk identifiers or snapshot" -); - -const OVERFLOW_ACTOR = `${ACTOR}-overflow`; -const overflowSnapshotId = "overflow-snapshot"; -const overflowTotalChunks = 18; -const overflowBaseCreatedAt = 1_700_555_000; - -const overflowPointers = Array.from({ length: overflowTotalChunks }, (_, index) => { - const relay = index % 3 === 0 ? "wss://overflow.unit" : null; - return { - type: index % 2 === 0 ? "e" : "a", - value: - index % 2 === 0 - ? `overflow-event-${index}` - : `${WATCH_HISTORY_KIND}:${OVERFLOW_ACTOR}:overflow-${index}`, - relay, - watchedAt: baseWatchedAt + index * 45_000, - }; -}); - -const overflowChunkEvents = overflowPointers.map((pointer, index) => { - const chunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/${overflowSnapshotId}/${index}`; - const pointerTag = [pointer.type, pointer.value]; - if (pointer.relay) { - pointerTag.push(pointer.relay); - } - const payload = { - version: 2, - snapshot: overflowSnapshotId, - chunkIndex: index, - totalChunks: overflowTotalChunks, - items: [pointer], - }; - const event = buildWatchHistoryChunkEvent({ - pubkey: OVERFLOW_ACTOR, - created_at: overflowBaseCreatedAt + (overflowTotalChunks - index), - chunkIdentifier, - snapshotId: overflowSnapshotId, - chunkIndex: index, - totalChunks: overflowTotalChunks, - pointerTags: [pointerTag], - chunkAddresses: [], - content: JSON.stringify(payload), - }); - event.id = window.NostrTools.getEventHash(event); - event.sig = `sig-overflow-chunk-${index}`; - return event; -}); - -const overflowIndexEvent = buildWatchHistoryIndexEvent({ - pubkey: OVERFLOW_ACTOR, - created_at: overflowBaseCreatedAt + overflowTotalChunks + 5, - snapshotId: overflowSnapshotId, - totalChunks: overflowTotalChunks, - chunkAddresses: [], -}); -overflowIndexEvent.id = window.NostrTools.getEventHash(overflowIndexEvent); -overflowIndexEvent.sig = "sig-overflow-index"; - -const overflowClient = createDecryptClient(OVERFLOW_ACTOR); -overflowClient.relays = ["wss://overflow.test"]; -// Simulate deployments that kept the fetch limit near the historical default. -overflowClient.watchHistoryFetchEventLimit = 12; - -const overflowChunkLimits = []; -overflowClient.pool = { - list: async (_relays, filters) => { - const firstFilter = filters[0] || {}; - const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : []; - if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { - return [overflowIndexEvent]; - } - - filters.forEach((filter) => { - if (Number.isFinite(filter?.limit)) { - overflowChunkLimits.push(filter.limit); - } - }); - - const requestedSnapshots = new Set(); - filters.forEach((filter) => { - if (Array.isArray(filter?.["#snapshot"])) { - filter["#snapshot"].forEach((value) => { - if (typeof value === "string" && value) { - requestedSnapshots.add(value); - } - }); - } - }); - - if (!requestedSnapshots.size) { - return []; - } - - return overflowChunkEvents.filter((event) => { - const snapshotTag = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "snapshot" - ); - return snapshotTag && requestedSnapshots.has(snapshotTag[1]); - }); - }, -}; - -const overflowFetched = await overflowClient.fetchWatchHistory(OVERFLOW_ACTOR); - -assert.equal( - overflowFetched.items.length, - overflowTotalChunks, - "fetch should hydrate snapshots larger than the legacy limit" -); - -const maxOverflowLimit = overflowChunkLimits.length - ? Math.max(...overflowChunkLimits) - : 0; - -assert( - maxOverflowLimit >= overflowTotalChunks + 1, - "chunk fetch should cover all chunks plus the newest index event" -); - -const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; -const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = - createPublishingClient(MONOTONIC_ACTOR); - -const monotonicOriginalDateNow = Date.now; -try { - const monotonicBaseSeconds = 1_700_123_456; - Date.now = () => monotonicBaseSeconds * 1000; - - const firstMonotonicPointers = [ - { type: "e", value: "event-monotonic-1", relay: null }, - { type: "e", value: "event-monotonic-2", relay: null }, - ]; - - const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( - MONOTONIC_ACTOR, - firstMonotonicPointers - ); - - const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry( - firstSnapshot.event, - firstMonotonicPointers, - Date.now() - ); - monotonicPublishingClient.watchHistoryCache.set( - MONOTONIC_ACTOR, - baselineEntry - ); - - const secondMonotonicPointers = [ - { type: "e", value: "event-monotonic-3", relay: null }, - { type: "e", value: "event-monotonic-1", relay: null }, - ]; - - const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( - MONOTONIC_ACTOR, - secondMonotonicPointers, - baselineEntry - ); - - assert( - secondSnapshot.event.created_at > firstSnapshot.event.created_at, - "new snapshot should advance created_at when reusing the same second" - ); - - const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR); - monotonicDecryptClient.pool = { list: async () => monotonicEvents }; - - const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory( - MONOTONIC_ACTOR - ); - - assert.deepEqual( - monotonicFetched.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - })), - [ - { type: "e", value: "event-monotonic-3", relay: null }, - { type: "e", value: "event-monotonic-1", relay: null }, - ], - "newer snapshot should win pointer ordering" - ); -} finally { - Date.now = monotonicOriginalDateNow; -} - -localStorage.clear(); - -const UPDATE_ACTOR = `${ACTOR}-update`; -const { client: updateClient } = createPublishingClient(UPDATE_ACTOR); -const updateOriginalDateNow = Date.now; -try { - const updateTimestamp = 1_701_111_111_111; - Date.now = () => updateTimestamp; - - const updateResult = await updateClient.updateWatchHistoryList({ - type: "e", - value: "update-pointer", - relay: null, - }); - - assert.equal( - updateResult.ok, - true, - "updateWatchHistoryList should publish when adding a new pointer" - ); - const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR); - assert(updateEntry, "updateWatchHistoryList should cache the new entry"); - assert.equal( - updateEntry.items[0].watchedAt, - updateTimestamp, - "cached entry should record watchedAt from Date.now()" - ); - assert.equal( - updateResult.items[0].watchedAt, - updateTimestamp, - "returned publish result should include the watchedAt timestamp" - ); -} finally { - Date.now = updateOriginalDateNow; -} - -const REMOVE_ACTOR = `${ACTOR}-remove`; -const { - client: removalClient, - payloads: removalPayloads, -} = createPublishingClient(REMOVE_ACTOR); - -const removalPointers = [ - { - type: "e", - value: "remove-first", - relay: null, - watchedAt: 1_701_222_000_000, - }, - { - type: "e", - value: "remove-second", - relay: null, - watchedAt: 1_701_222_100_000, - }, -]; - -await removalClient.publishWatchHistorySnapshot( - REMOVE_ACTOR, - removalPointers -); - -const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`; -const removalResult = await removalClient.removeWatchHistoryItem(removalKey); - -assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed"); -assert.equal( - removalResult.removedKey, - removalKey, - "removeWatchHistoryItem should echo the removed pointer key" -); - -const latestRemovalPayload = removalPayloads[removalPayloads.length - 1]; -assert.equal( - latestRemovalPayload.items.length, - 1, - "removal republish should trim the pointer list" -); -assert.equal( - latestRemovalPayload.items[0].value, - removalPointers[0].value, - "republished payload should only include remaining pointer" -); -assert.equal( - latestRemovalPayload.items[0].watchedAt, - removalPointers[0].watchedAt, - "republished payload should preserve watchedAt" -); - -const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR); -assert.equal( - removalEntry.items.length, - 1, - "watch history cache should drop the removed pointer" -); -assert.equal( - removalEntry.items[0].value, - removalPointers[0].value, - "remaining cached pointer should match the survivor" -); -assert.equal( - removalEntry.items[0].watchedAt, - removalPointers[0].watchedAt, - "remaining cached pointer should retain watchedAt" -); - -assert.equal( - removalResult.items.map((item) => item.value).join(","), - removalPointers.slice(0, 1).map((item) => item.value).join(","), - "removeWatchHistoryItem result should reflect updated items" -); -assert.equal( - removalResult.items[0].watchedAt, - removalPointers[0].watchedAt, - "removeWatchHistoryItem result should preserve watchedAt" -); - -const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`; -const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier"; -const videoPointer = { - type: "a", - value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`, - relay: null, -}; - -const pointerClient = createDecryptClient(ACTOR); -const pointerEntry = pointerClient.createWatchHistoryEntry( - null, - [videoPointer], - Date.now() -); -pointerClient.watchHistoryCache.set(ACTOR, pointerEntry); -pointerClient.fetchWatchHistory = async () => {}; - -const observedResolveFilters = []; -const videoEvent = { - id: "video-pointer-event", - kind: WATCH_HISTORY_KIND, - pubkey: VIDEO_POINTER_AUTHOR, - created_at: 1_700_555_000, - tags: [["d", VIDEO_POINTER_IDENTIFIER]], - content: JSON.stringify({ - version: 3, - title: "Pointer Video", - url: "https://cdn.example.com/video.mp4", - }), -}; - -pointerClient.pool = { - list: async (_relays, filters) => { - observedResolveFilters.push(filters); - return [videoEvent]; - }, -}; - -const resolvedFromPointer = await pointerClient.resolveWatchHistory(1); - -const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; - -assert.equal( - resolvedFromPointer.length, - 1, - "resolveWatchHistory should return results for 'a' style video pointers" -); -assert.equal( - resolvedFromPointer[0].id, - videoEvent.id, - "resolved video should match the pointer event id" -); -assert.equal( - resolvedFromPointer[0].title, - "Pointer Video", - "resolved video should parse title from the event payload" -); - -assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata"); -assert.equal( - resolvedFromPointer[0].watchHistory.key, - pointerKey, - "watchHistory metadata should expose the pointer key" -); -assert.equal( - resolvedFromPointer[0].watchHistory.pointer.value, - videoPointer.value, - "watchHistory metadata should include the pointer payload" -); -assert.equal( - resolvedFromPointer[0].watchHistory.pointer.watchedAt, - pointerEntry.items[0].watchedAt, - "watchHistory metadata should preserve watchedAt" -); - -const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); -assert( - cachedEntry.resolvedVideos.has(pointerKey), - "resolved cache should retain the video" -); -assert.equal( - cachedEntry.resolvedVideos.get(pointerKey)?.id, - videoEvent.id, - "resolved cache should store the converted video result" -); -assert( - cachedEntry.delivered.has(pointerKey), - "delivery set should mark the pointer as delivered" -); - -assert( - observedResolveFilters.some((filters) => - filters.some((filter) => - Array.isArray(filter?.["#d"]) && - filter["#d"].includes(VIDEO_POINTER_IDENTIFIER) - ) - ), - "resolveWatchHistory should request the video identifier instead of treating it as a chunk" -); - -const secondResolve = await pointerClient.resolveWatchHistory(1); -assert.equal( - secondResolve.length, - 0, - "subsequent resolve calls should avoid refetching already delivered videos" -); - -pointerClient.resetWatchHistoryProgress(ACTOR); -assert.equal( - cachedEntry.delivered.size, - 0, - "resetWatchHistoryProgress should clear delivered pointers" -); - -pointerClient.pool = { - list: async () => { - throw new Error("cached resolve should not trigger network fetch"); - }, -}; - -const cachedResolve = await pointerClient.resolveWatchHistory(1); -assert.equal( - cachedResolve.length, - 1, - "reset should allow cached videos to be delivered again" -); -assert.equal( - cachedResolve[0].id, - videoEvent.id, - "cached resolve should reuse the stored video payload" -); -assert( - cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt, - "cached resolve should retain watchHistory metadata" -); - -const originalDateNow = Date.now; -try { - const baseTime = 1_700_000_000_000; - const ttlActor = `${ACTOR}-ttl`; - const pointer = { type: "e", value: "event-ttl", relay: null }; - - const cachingClient = createDecryptClient(ttlActor); - cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000; - - Date.now = () => baseTime; - - const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime); - cachingClient.watchHistoryCache.set(ttlActor, entry); - cachingClient.persistWatchHistoryEntry(ttlActor, entry); - - const reloadClient = createDecryptClient(ttlActor); - reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs; - - Date.now = () => baseTime + 60 * 60 * 1000; - - const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor); - assert.equal( - cachedSnapshot.items.length, - 1, - "cached snapshot should survive within extended TTL" - ); - assert.equal( - cachedSnapshot.items[0].value, - pointer.value, - "cached snapshot should preserve pointer" - ); - - localStorage.clear(); - - const expiryClient = createDecryptClient(ttlActor); - expiryClient.watchHistoryCacheTtlMs = 1_000; - - Date.now = () => baseTime; - - const expiringEntry = expiryClient.createWatchHistoryEntry( - null, - [pointer], - baseTime - ); - expiryClient.watchHistoryCache.set(ttlActor, expiringEntry); - expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry); - - Date.now = () => baseTime + 1_500; - - const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor); - assert.equal( - expiredSnapshot.items.length, - 0, - "expired snapshot should be dropped" - ); - const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor); - assert.equal( - cachedFallback?.items?.length ?? 0, - 0, - "expired snapshot should not preserve stale items in memory" - ); - const storage = expiryClient.getWatchHistoryStorage(); - assert.equal( - Object.prototype.hasOwnProperty.call(storage.actors, ttlActor), - false, - "expired snapshot should be removed from localStorage" - ); -} finally { - Date.now = originalDateNow; -} - -const plaintextPayload = { - version: 2, - snapshot: "plaintext-snapshot", - chunkIndex: 0, - totalChunks: 1, - items: [ - { - type: "e", - value: "plaintext-pointer", - relay: "wss://relay.unit", - watchedAt: 1_700_000_123_000, - }, - ], -}; - -const plaintextChunkEvent = { - kind: WATCH_HISTORY_KIND, - pubkey: ACTOR, - created_at: 333, - tags: [ - ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`], - ["snapshot", plaintextPayload.snapshot], - ["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)], - ["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay], - ], - content: JSON.stringify(plaintextPayload), -}; - -const originalWindowNostr = globalThis.window.nostr; -if (!globalThis.window.NostrTools) { - globalThis.window.NostrTools = {}; -} -if (!globalThis.window.NostrTools.nip04) { - globalThis.window.NostrTools.nip04 = {}; -} -const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; - -try { - globalThis.window.nostr = { - nip04: { - decrypt: () => { - throw new Error("plaintext chunk should not trigger extension decrypt"); - }, - }, - }; - globalThis.window.NostrTools.nip04.decrypt = () => { - throw new Error("plaintext chunk should not trigger session decrypt"); - }; - - const plaintextClient = createDecryptClient(ACTOR); - const plaintextResult = await plaintextClient.decryptWatchHistoryEvent( - plaintextChunkEvent, - ACTOR - ); - - assert.deepEqual( - plaintextResult.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay, - watchedAt: item.watchedAt, - })), - plaintextPayload.items, - "plaintext payload should be parsed without invoking nip04 helpers", - ); - assert.equal( - plaintextResult.snapshot, - plaintextPayload.snapshot, - "plaintext payload should preserve snapshot metadata", - ); - assert.equal( - plaintextResult.chunkIndex, - plaintextPayload.chunkIndex, - "plaintext payload should preserve chunk index", - ); - assert.equal( - plaintextResult.totalChunks, - plaintextPayload.totalChunks, - "plaintext payload should preserve chunk count", - ); -} finally { - globalThis.window.nostr = originalWindowNostr; - if (originalSessionDecrypt === undefined) { - delete globalThis.window.NostrTools.nip04.decrypt; - } else { - globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt; - } -} - -const encryptedPayload = { - version: 2, - snapshot: "encrypted-snapshot", - chunkIndex: 0, - totalChunks: 1, - items: [ - { - type: "e", - value: "encrypted-pointer", - relay: "wss://relay.unit", - watchedAt: 1_700_000_456_000, - }, - ], -}; - -const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz"; - -const encryptedChunkEvent = { - kind: WATCH_HISTORY_KIND, - pubkey: ACTOR, - created_at: 444, - tags: [ - ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`], - ["encrypted", "nip04"], - ["snapshot", encryptedPayload.snapshot], - ["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)], - ["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay], - ], - content: encryptedCiphertext, -}; - -const encryptedClient = createDecryptClient(ACTOR); -encryptedClient.pubkey = ACTOR; - -const priorWindowNostr = globalThis.window.nostr; -const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; -let extensionDecryptCalls = 0; -let sessionDecryptCalls = 0; - -try { - globalThis.window.nostr = { - nip04: { - decrypt: async (pubkey, ciphertext) => { - extensionDecryptCalls += 1; - assert.equal(pubkey, ACTOR, "extension decrypt should target the actor"); - assert.equal( - ciphertext, - encryptedCiphertext, - "extension decrypt should receive the chunk ciphertext", - ); - return JSON.stringify(encryptedPayload); - }, - }, - }; - - globalThis.window.NostrTools.nip04.decrypt = async () => { - sessionDecryptCalls += 1; - return JSON.stringify(encryptedPayload); - }; - - const decryptedResult = await encryptedClient.decryptWatchHistoryEvent( - encryptedChunkEvent, - ACTOR - ); - - assert.equal( - extensionDecryptCalls, - 1, - "encrypted chunks should invoke extension nip04 decrypt", - ); - assert.equal( - sessionDecryptCalls, - 0, - "successful extension decrypt should skip session fallback", - ); - assert.deepEqual( - decryptedResult.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay, - watchedAt: item.watchedAt, - })), - encryptedPayload.items, - "encrypted payload should be reconstructed from decrypted content", - ); - assert.equal( - decryptedResult.snapshot, - encryptedPayload.snapshot, - "encrypted payload should preserve snapshot metadata", - ); -} finally { - globalThis.window.nostr = priorWindowNostr; - if (priorSessionDecrypt === undefined) { - delete globalThis.window.NostrTools.nip04.decrypt; - } else { - globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt; - } -} - -const missingSignerClient = createDecryptClient(LOGGED_ACTOR); -missingSignerClient.pubkey = LOGGED_ACTOR; -missingSignerClient.sessionActor = null; - -let missingSignerEnsureCalls = 0; -missingSignerClient.ensureSessionActor = async () => { - missingSignerEnsureCalls += 1; - return LOGGED_ACTOR; -}; - -const priorMissingSignerNostr = globalThis.window.nostr; -const priorMissingSignerSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; -let missingSignerSessionDecryptCalls = 0; - -try { - delete globalThis.window.nostr; - globalThis.window.NostrTools.nip04.decrypt = async () => { - missingSignerSessionDecryptCalls += 1; - return JSON.stringify(encryptedPayload); - }; - - const missingSignerChunkEvent = { - ...encryptedChunkEvent, - pubkey: LOGGED_ACTOR, - id: "missing-signer-event", - }; - - const missingSignerResult = await missingSignerClient.decryptWatchHistoryEvent( - missingSignerChunkEvent, - LOGGED_ACTOR - ); - - assert.equal( - missingSignerEnsureCalls, - 0, - "missing signer should not trigger session actor ensure" - ); - assert.equal( - missingSignerSessionDecryptCalls, - 0, - "missing signer should skip session decrypt fallback" - ); - assert.equal( - missingSignerResult.needsSignerRetry, - true, - "missing signer fallback should mark retry flag" - ); - assert.deepEqual( - missingSignerResult.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - })), - encryptedPayload.items.map((item) => ({ - type: item.type, - value: item.value, - relay: item.relay || null, - })), - "missing signer fallback should preserve pointer metadata" - ); -} finally { - if (priorMissingSignerNostr === undefined) { - delete globalThis.window.nostr; - } else { - globalThis.window.nostr = priorMissingSignerNostr; - } - if (priorMissingSignerSessionDecrypt === undefined) { - delete globalThis.window.NostrTools.nip04.decrypt; - } else { - globalThis.window.NostrTools.nip04.decrypt = priorMissingSignerSessionDecrypt; - } -} - -const unsortedVideos = [ - { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, - { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, - { id: "charlie", created_at: 1_699_999_000 }, -]; - -const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc); - -assert.deepEqual( - sortedVideos.map((video) => video.id), - ["bravo", "alpha", "charlie"], - "videos should be ordered by watchedAt descending with created_at fallback", -); - -const tieVideos = [ - { id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 }, - { id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, - { id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, -]; - -tieVideos.sort(compareByWatchedAtDesc); - -assert.equal( - getWatchedAtScore(tieVideos[0]), - getWatchedAtScore(tieVideos[1]), - "ties should preserve identical watchedAt scores", -); - -assert.deepEqual( - tieVideos.map((video) => video.id), - ["delta", "foxtrot", "echo"], - "created_at should break watchedAt ties with stable fallback", -); - -console.log("watch history tests passed"); From 57655aa304cc2f910ca14a3d6e18c5fdc57fd822 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:51:22 -0400 Subject: [PATCH 62/82] fix: restore view counter support --- js/nostr.js | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) diff --git a/js/nostr.js b/js/nostr.js index 328a40e6..8fc9dd1b 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1462,6 +1462,332 @@ class NostrClient { return []; } + 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 publishViewEvent(videoPointer, options = {}) { if (!this.pool) { return { ok: false, error: "nostr-uninitialized" }; From 0320492c54d0cf4f55517088efbb3f4b332ecd93 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:01:09 -0400 Subject: [PATCH 63/82] fix: restore session actor for view events --- js/nostr.js | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/js/nostr.js b/js/nostr.js index 8fc9dd1b..c48c9989 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1279,6 +1279,232 @@ class NostrClient { this.countUnsupportedRelays = new Set(); } + restoreSessionActorFromStorage() { + if (typeof localStorage === "undefined") { + return null; + } + + let raw = null; + try { + raw = localStorage.getItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to read session actor from storage:", error); + } + return null; + } + + if (!raw || typeof raw !== "string") { + return null; + } + + try { + const parsed = JSON.parse(raw); + const pubkey = + typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; + const privateKey = + typeof parsed?.privateKey === "string" + ? parsed.privateKey.trim() + : ""; + + if (!pubkey || !privateKey) { + return null; + } + + return { + pubkey, + privateKey, + createdAt: Number.isFinite(parsed?.createdAt) + ? parsed.createdAt + : Date.now(), + }; + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse stored session actor:", error); + } + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[nostr] Failed to clear corrupt session actor entry:", + cleanupError + ); + } + } + } + + return null; + } + + persistSessionActor(actor) { + if (typeof localStorage === "undefined") { + return; + } + + if ( + !actor || + typeof actor.pubkey !== "string" || + !actor.pubkey || + typeof actor.privateKey !== "string" || + !actor.privateKey + ) { + return; + } + + const payload = { + pubkey: actor.pubkey, + privateKey: actor.privateKey, + createdAt: Number.isFinite(actor.createdAt) + ? actor.createdAt + : Date.now(), + }; + + try { + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify(payload) + ); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to persist session actor:", error); + } + } + } + + clearStoredSessionActor() { + if (typeof localStorage === "undefined") { + return; + } + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to clear stored session actor:", error); + } + } + } + + mintSessionActor() { + const tools = window?.NostrTools || null; + if (!tools) { + if (isDevMode) { + console.warn("[nostr] Cannot mint session actor without NostrTools."); + } + return null; + } + + const getPublicKey = + typeof tools.getPublicKey === "function" ? tools.getPublicKey : null; + if (!getPublicKey) { + if (isDevMode) { + console.warn( + "[nostr] Cannot mint session actor: missing getPublicKey helper." + ); + } + return null; + } + + let privateKey = ""; + try { + if (typeof tools.generatePrivateKey === "function") { + privateKey = tools.generatePrivateKey(); + } else if (window?.crypto?.getRandomValues) { + const randomBytes = new Uint8Array(32); + window.crypto.getRandomValues(randomBytes); + privateKey = Array.from(randomBytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to mint session private key:", error); + } + privateKey = ""; + } + + if (!privateKey || typeof privateKey !== "string") { + return null; + } + + const normalizedPrivateKey = privateKey.trim(); + if (!normalizedPrivateKey) { + return null; + } + + let pubkey = ""; + try { + pubkey = getPublicKey(normalizedPrivateKey); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to derive session pubkey:", error); + } + return null; + } + + const normalizedPubkey = + typeof pubkey === "string" ? pubkey.trim() : ""; + if (!normalizedPubkey) { + return null; + } + + return { + pubkey: normalizedPubkey, + privateKey: normalizedPrivateKey, + createdAt: Date.now(), + }; + } + + async ensureSessionActor(forceRenew = false) { + const normalizedLogged = + typeof this.pubkey === "string" && this.pubkey + ? this.pubkey.toLowerCase() + : ""; + const extension = window?.nostr; + const canSignWithExtension = + !!normalizedLogged && + extension && + typeof extension.signEvent === "function"; + + if (!forceRenew && canSignWithExtension) { + return normalizedLogged; + } + + if (forceRenew) { + this.sessionActor = null; + this.clearStoredSessionActor(); + } else if ( + this.sessionActor && + typeof this.sessionActor.pubkey === "string" && + this.sessionActor.pubkey && + typeof this.sessionActor.privateKey === "string" && + this.sessionActor.privateKey + ) { + return this.sessionActor.pubkey; + } + + if (!forceRenew) { + const restored = this.restoreSessionActorFromStorage(); + if (restored) { + this.sessionActor = restored; + return restored.pubkey; + } + } + + const minted = this.mintSessionActor(); + if (minted) { + this.sessionActor = minted; + this.persistSessionActor(minted); + return minted.pubkey; + } + + if (canSignWithExtension) { + return normalizedLogged; + } + + return null; + } + makeCountUnsupportedError(relayUrl) { const normalizedUrl = typeof relayUrl === "string" && relayUrl.trim() From 7f586a38c400a269e3850a2e7390b255b548df12 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:30:12 -0400 Subject: [PATCH 64/82] Refactor magnet helpers into shared module --- js/magnet.js | 236 ++++------------------- js/magnetShared.js | 360 ++++++++++++++++++++++++++++++++++++ js/magnetUtils.js | 305 +++++------------------------- tests/magnet-utils.test.mjs | 293 ++++++++++++++++------------- 4 files changed, 604 insertions(+), 590 deletions(-) create mode 100644 js/magnetShared.js diff --git a/js/magnet.js b/js/magnet.js index f4282ea7..8f8eb005 100644 --- a/js/magnet.js +++ b/js/magnet.js @@ -1,224 +1,50 @@ -import { WSS_TRACKERS } from "./constants.js"; +import { + buildMagnetUri, + ensureTrackers, + ensureTorrentHint, + ensureWebSeeds, + formatAbsoluteUrl, + normalizeMagnetInput, + resolveAppProtocol, + extractMagnetHints as sharedExtractMagnetHints, +} from "./magnetShared.js"; -const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; -const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; +export const extractMagnetHints = sharedExtractMagnetHints; -function getOriginProtocol() { - if (typeof window !== "undefined" && window.location?.protocol) { - return window.location.protocol.toLowerCase(); - } - return "https:"; -} - -function decodeLoose(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - try { - return decodeURIComponent(trimmed); - } catch (err) { - return trimmed; - } -} - -function normalizeForComparison(value) { - if (typeof value !== "string") { - return ""; - } - return value.trim().replace(/\/+$/, "").toLowerCase(); -} - -function tryFormatAbsoluteUrl(candidate) { - if (typeof candidate !== "string") { - return ""; - } - const trimmed = candidate.trim(); - if (!trimmed) { - return ""; - } - try { - const parsed = new URL(trimmed); - return parsed.toString(); - } catch (err) { - return trimmed; - } -} - -/** - * Normalize and enrich a magnet string entered in the upload form so that it - * always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of - * the legacy magnetUtils helper but returns a plain string for ease of use. - */ export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) { - const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + const { + initial, + canonicalValue, + isMagnet, + normalizedScheme, + fragment, + params, + } = normalizeMagnetInput(rawValue); + if (!initial) { return ""; } - let working = initial; - - if (HEX_INFO_HASH.test(working)) { - working = `magnet:?xt=urn:btih:${working.toLowerCase()}`; + if (!isMagnet) { + return canonicalValue; } - working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`); - - if (!/^magnet:/i.test(working)) { - return working; - } - - let fragment = ""; - const hashIndex = working.indexOf("#"); - if (hashIndex !== -1) { - fragment = working.slice(hashIndex); - working = working.slice(0, hashIndex); - } - - const [, queryPart = ""] = working.split("?", 2); - const normalizedScheme = "magnet:"; - const rawParams = queryPart - .split("&") - .map((part) => part.trim()) - .filter(Boolean); - - const params = []; - - for (const rawParam of rawParams) { - const [rawKey, rawVal = ""] = rawParam.split("=", 2); - const key = rawKey.trim(); - if (!key) { - continue; - } - let value = rawVal.trim(); - if (key.toLowerCase() === "xt" && value) { - const decoded = decodeLoose(value); - if (decoded) { - value = decoded; - } - } - params.push({ - key, - value, - lowerKey: key.toLowerCase(), - compareValue: normalizeForComparison(value), - }); - } - - const appendUniqueParam = (key, value) => { - if (!value) { - return; - } - const trimmedValue = value.trim(); - if (!trimmedValue) { - return; - } - const lowerKey = key.toLowerCase(); - const compareValue = normalizeForComparison(trimmedValue); - if (compareValue) { - const exists = params.some( - (param) => param.lowerKey === lowerKey && param.compareValue === compareValue - ); - if (exists) { - return; - } - } - params.push({ - key, - value: trimmedValue, - lowerKey, - compareValue, - }); - }; - - for (const tracker of WSS_TRACKERS) { - if (typeof tracker !== "string") { - continue; - } - const trimmedTracker = tracker.trim(); - if (!/^wss:\/\//i.test(trimmedTracker)) { - continue; - } - appendUniqueParam("tr", trimmedTracker); - } + ensureTrackers(params); const normalizedXs = typeof xs === "string" ? xs.trim() : ""; if (normalizedXs) { - appendUniqueParam("xs", normalizedXs); + ensureTorrentHint(params, normalizedXs, { requireHttp: false }); } - const originProtocol = getOriginProtocol(); const rawWs = typeof ws === "string" ? ws.trim() : ""; if (rawWs) { - const formattedWs = tryFormatAbsoluteUrl(rawWs); - const lowerWs = formattedWs.toLowerCase(); - const isHttpSeed = lowerWs.startsWith("http://"); - const allowHttpSeed = originProtocol === "http:"; - if (!isHttpSeed || allowHttpSeed) { - appendUniqueParam("ws", formattedWs); - } + const formattedWs = formatAbsoluteUrl(rawWs); + const allowHttpSeed = resolveAppProtocol() === "http:"; + ensureWebSeeds(params, formattedWs, { + allowHttp: allowHttpSeed, + allowUnparsed: true, + }); } - const queryString = params - .map(({ key, value }) => (value ? `${key}=${value}` : key)) - .join("&"); - - return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; -} - -export function extractMagnetHints(rawValue) { - const hints = { ws: "", xs: "" }; - if (typeof rawValue !== "string") { - return hints; - } - - const trimmed = rawValue.trim(); - if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) { - return hints; - } - - const queryIndex = trimmed.indexOf("?"); - if (queryIndex === -1 || queryIndex === trimmed.length - 1) { - return hints; - } - - const query = trimmed.slice(queryIndex + 1); - if (!query) { - return hints; - } - - const parts = query.split("&"); - for (const part of parts) { - if (!part) { - continue; - } - const [rawKey, rawValue = ""] = part.split("=", 2); - if (!rawKey) { - continue; - } - const lowerKey = rawKey.trim().toLowerCase(); - if (lowerKey !== "ws" && lowerKey !== "xs") { - continue; - } - if (hints[lowerKey]) { - continue; - } - - let decoded = rawValue; - try { - decoded = decodeURIComponent(rawValue); - } catch (err) { - // Ignore decode errors and use the raw value. - } - const clean = typeof decoded === "string" ? decoded.trim() : ""; - if (!clean) { - continue; - } - hints[lowerKey] = clean; - } - - return hints; + return buildMagnetUri(normalizedScheme, params, fragment); } diff --git a/js/magnetShared.js b/js/magnetShared.js new file mode 100644 index 00000000..a71f18fe --- /dev/null +++ b/js/magnetShared.js @@ -0,0 +1,360 @@ +import { WSS_TRACKERS } from "./constants.js"; + +const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; +const BTIH_PREFIX = "urn:btih:"; +const MAGNET_SCHEME = "magnet:"; +const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; + +function decodeLoose(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + return decodeURIComponent(trimmed); + } catch (err) { + return trimmed; + } +} + +export function safeDecodeMagnet(value) { + if (typeof value !== "string") { + return ""; + } + + let decoded = value.trim(); + if (!decoded) { + return ""; + } + + for (let i = 0; i < 2; i += 1) { + if (!decoded.includes("%")) { + break; + } + + try { + const candidate = decodeURIComponent(decoded); + if (!candidate || candidate === decoded) { + break; + } + decoded = candidate.trim(); + } catch (err) { + break; + } + } + + return decoded; +} + +export function normalizeForComparison(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + const pathname = parsed.pathname.replace(/\/?$/, ""); + const normalizedPath = pathname ? pathname : ""; + return ( + `${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}` + .trim() + .toLowerCase() + ); + } catch (err) { + return trimmed.replace(/\/?$/, "").toLowerCase(); + } +} + +function createParam(key, value) { + const trimmedValue = typeof value === "string" ? value.trim() : ""; + const decoded = decodeLoose(trimmedValue); + const comparisonBasis = decoded || trimmedValue; + return { + key, + value: trimmedValue, + lowerKey: key.toLowerCase(), + decoded, + comparison: normalizeForComparison(comparisonBasis), + }; +} + +export function normalizeMagnetInput(rawValue) { + const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + if (!initial) { + return { + initial, + canonicalValue: "", + didMutate: false, + isMagnet: false, + normalizedScheme: "", + fragment: "", + params: [], + }; + } + + let working = initial; + let didMutate = false; + + if (HEX_INFO_HASH.test(working)) { + working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`; + didMutate = true; + } + + ENCODED_BTih_PATTERN.lastIndex = 0; + const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { + didMutate = true; + return `xt=${BTIH_PREFIX}${hash}`; + }); + working = decodedXt; + + let fragment = ""; + const hashIndex = working.indexOf("#"); + if (hashIndex !== -1) { + fragment = working.slice(hashIndex); + working = working.slice(0, hashIndex); + } + + const canonicalValue = working; + if (!/^magnet:/i.test(working)) { + return { + initial, + canonicalValue, + didMutate: didMutate || canonicalValue !== initial, + isMagnet: false, + normalizedScheme: "", + fragment: "", + params: [], + }; + } + + const [schemePart, queryPart = ""] = working.split("?", 2); + const normalizedScheme = MAGNET_SCHEME; + if (schemePart !== normalizedScheme) { + didMutate = true; + } + + const rawParams = queryPart + .split("&") + .map((part) => part.trim()) + .filter(Boolean); + + const params = []; + for (const rawParam of rawParams) { + const [rawKey, rawVal = ""] = rawParam.split("=", 2); + const key = rawKey.trim(); + if (!key) { + continue; + } + const lowerKey = key.toLowerCase(); + let value = rawVal.trim(); + if (lowerKey === "xt" && value) { + const decoded = decodeLoose(value); + if (decoded && decoded !== value) { + value = decoded; + didMutate = true; + } + } + params.push(createParam(key, value)); + } + + return { + initial, + canonicalValue, + didMutate, + isMagnet: true, + normalizedScheme, + fragment, + params, + }; +} + +export function buildMagnetUri(normalizedScheme, params, fragment) { + const queryString = params + .map(({ key, value }) => (value ? `${key}=${value}` : key)) + .join("&"); + return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`; +} + +export function appendUniqueParam(params, key, value) { + if (!Array.isArray(params)) { + return false; + } + const candidate = createParam(key, value); + if (!candidate.value && candidate.value !== "") { + return false; + } + if (candidate.comparison) { + const exists = params.some( + (param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison + ); + if (exists) { + return false; + } + } else { + const exists = params.some( + (param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value + ); + if (exists) { + return false; + } + } + params.push(candidate); + return true; +} + +export function ensureTrackers(params, trackers = WSS_TRACKERS) { + let didMutate = false; + for (const tracker of trackers) { + if (typeof tracker !== "string") { + continue; + } + const trimmedTracker = tracker.trim(); + if (!trimmedTracker) { + continue; + } + if (!/^wss:\/\//i.test(trimmedTracker)) { + continue; + } + if (appendUniqueParam(params, "tr", trimmedTracker)) { + didMutate = true; + } + } + return didMutate; +} + +export function sanitizeHttpUrl(candidate) { + if (typeof candidate !== "string") { + return ""; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch (err) { + return ""; + } + return ""; +} + +export function ensureTorrentHint(params, candidate, { requireHttp = true } = {}) { + if (requireHttp) { + const sanitized = sanitizeHttpUrl(candidate); + if (!sanitized) { + return false; + } + return appendUniqueParam(params, "xs", sanitized); + } + + if (typeof candidate !== "string") { + return false; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return false; + } + return appendUniqueParam(params, "xs", trimmed); +} + +export function resolveAppProtocol(explicitProtocol) { + if (typeof explicitProtocol === "string" && explicitProtocol.trim()) { + return explicitProtocol.trim().toLowerCase(); + } + if (typeof window !== "undefined" && window.location?.protocol) { + return window.location.protocol.toLowerCase(); + } + return "https:"; +} + +export function ensureWebSeeds( + params, + seeds, + { allowHttp = false, allowUnparsed = false, logger } = {} +) { + const list = Array.isArray(seeds) + ? seeds + : typeof seeds === "string" + ? [seeds] + : []; + let didMutate = false; + for (const seed of list) { + if (typeof seed !== "string") { + continue; + } + const trimmedSeed = seed.trim(); + if (!trimmedSeed) { + continue; + } + let finalValue = trimmedSeed; + let parsed; + try { + parsed = new URL(trimmedSeed); + const protocol = parsed.protocol; + if (protocol === "https:") { + finalValue = parsed.toString(); + } else if (protocol === "http:") { + if (!allowHttp) { + if (typeof logger === "function") { + logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`); + } + continue; + } + finalValue = parsed.toString(); + } else if (!allowUnparsed) { + continue; + } + } catch (err) { + if (!allowUnparsed) { + continue; + } + } + + if (appendUniqueParam(params, "ws", finalValue)) { + didMutate = true; + } + } + return didMutate; +} + +export function formatAbsoluteUrl(candidate) { + if (typeof candidate !== "string") { + return ""; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + return parsed.toString(); + } catch (err) { + return trimmed; + } +} + +export function extractMagnetHints(rawValue) { + const { params } = normalizeMagnetInput(rawValue); + const hints = { ws: "", xs: "" }; + for (const param of params) { + if (param.lowerKey === "ws" && !hints.ws) { + hints.ws = param.decoded || param.value; + } else if (param.lowerKey === "xs" && !hints.xs) { + hints.xs = param.decoded || param.value; + } + if (hints.ws && hints.xs) { + break; + } + } + return hints; +} + diff --git a/js/magnetUtils.js b/js/magnetUtils.js index 29057dc3..7861767b 100644 --- a/js/magnetUtils.js +++ b/js/magnetUtils.js @@ -1,101 +1,19 @@ // js/magnetUtils.js import { WSS_TRACKERS } from "./constants.js"; +import { + buildMagnetUri, + ensureTrackers, + ensureTorrentHint, + ensureWebSeeds, + normalizeMagnetInput, + resolveAppProtocol, + safeDecodeMagnet as sharedSafeDecodeMagnet, +} from "./magnetShared.js"; export { WSS_TRACKERS }; +export const safeDecodeMagnet = sharedSafeDecodeMagnet; -const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; -const BTIH_PREFIX = "urn:btih:"; -const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; - -function normalizeForComparison(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - try { - const parsed = new URL(trimmed); - const pathname = parsed.pathname.replace(/\/?$/, ""); - const normalizedPath = pathname ? pathname : ""; - return ( - `${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}` - .trim() - .toLowerCase() - ); - } catch (err) { - return trimmed.replace(/\/?$/, "").toLowerCase(); - } -} - -function sanitizeHttpUrl(candidate) { - if (typeof candidate !== "string") { - return ""; - } - const trimmed = candidate.trim(); - if (!trimmed) { - return ""; - } - try { - const parsed = new URL(trimmed); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - return parsed.toString(); - } - } catch (err) { - return ""; - } - return ""; -} - -export function safeDecodeMagnet(value) { - if (typeof value !== "string") { - return ""; - } - - let decoded = value.trim(); - if (!decoded) { - return ""; - } - - for (let i = 0; i < 2; i += 1) { - if (!decoded.includes("%")) { - break; - } - - try { - const candidate = decodeURIComponent(decoded); - if (!candidate) { - break; - } - if (candidate === decoded) { - break; - } - decoded = candidate.trim(); - } catch (err) { - break; - } - } - - return decoded; -} - -/** - * Normalize a magnet URI while preserving legacy payload quirks and augmenting it - * for browser playback. - * - * Key behaviors: - * - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links. - * - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization - * beyond decoding legacy `%3A` segments. - * - Only appends browser-safe WSS trackers in addition to whatever the caller provides. - * - Skips insecure `http:` web seeds unless the application itself is running over HTTP. - * - * Note: `didChange` may be reported even if the resulting magnet string is textually - * identical to the input. Callers should not rely on `didChange` to detect a modified - * URI. - */ export function normalizeAndAugmentMagnet( rawValue, { @@ -107,83 +25,32 @@ export function normalizeAndAugmentMagnet( appProtocol, } = {} ) { - const log = typeof logger === "function" ? logger : () => {}; - const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + const { + initial, + canonicalValue, + didMutate, + isMagnet, + normalizedScheme, + fragment, + params, + } = normalizeMagnetInput(rawValue); + if (!initial) { return { magnet: "", didChange: false }; } - let working = initial; - let didMutate = false; - const bareHashMatch = HEX_INFO_HASH.test(working); - if (bareHashMatch) { - working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`; - didMutate = true; - } - - const hashIndex = working.indexOf("#"); - let fragment = ""; - if (hashIndex !== -1) { - fragment = working.slice(hashIndex); - working = working.slice(0, hashIndex); - } - - const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { - didMutate = true; - return `xt=${BTIH_PREFIX}${hash}`; - }); - working = decodedXt; - - if (!/^magnet:/i.test(working)) { + if (!isMagnet) { + const magnet = canonicalValue; return { - magnet: working, - didChange: didMutate || working !== initial, + magnet, + didChange: didMutate || magnet !== initial, }; } - const [schemePart, queryPart = ""] = working.split("?", 2); - const normalizedScheme = "magnet:"; - if (schemePart !== normalizedScheme) { - didMutate = true; - } + let didChange = didMutate; - const rawParams = queryPart - .split("&") - .map((part) => part.trim()) - .filter(Boolean); - - const params = []; - - const decodeLoose = (value) => { - if (typeof value !== "string" || !value) { - return ""; - } - try { - return decodeURIComponent(value); - } catch (err) { - return value; - } - }; - - for (const rawParam of rawParams) { - const [rawKey, rawValue = ""] = rawParam.split("=", 2); - const key = rawKey.trim(); - if (!key) { - continue; - } - let value = rawValue.trim(); - if (key.toLowerCase() === "xt" && value) { - const decoded = decodeLoose(value); - if (decoded !== value) { - value = decoded; - didMutate = true; - } - } - params.push({ - key, - value, - decoded: decodeLoose(value), - }); + if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) { + didChange = true; } const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim() @@ -192,118 +59,34 @@ export function normalizeAndAugmentMagnet( ? xs.trim() : ""; - const safeTorrentHint = sanitizeHttpUrl(torrentHint); + if (torrentHint) { + if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) { + didChange = true; + } + } + const seedInputs = Array.isArray(webSeed) ? webSeed : typeof webSeed === "string" ? [webSeed] : []; - const resolvedProtocol = typeof appProtocol === "string" && appProtocol - ? appProtocol.toLowerCase() - : typeof window !== "undefined" && window.location && window.location.protocol - ? window.location.protocol.toLowerCase() - : "https:"; + const resolvedProtocol = resolveAppProtocol(appProtocol); - const existingTrackers = new Set( - params - .filter((param) => param.key.toLowerCase() === "tr") - .map((param) => normalizeForComparison(param.decoded)) - .filter(Boolean) - ); - - const trackerCandidates = [ - ...WSS_TRACKERS, - ...extraTrackers, - ]; - - for (const tracker of trackerCandidates) { - if (typeof tracker !== "string") { - continue; - } - const trimmedTracker = tracker.trim(); - if (!trimmedTracker) { - continue; - } - if (!/^wss:\/\//i.test(trimmedTracker)) { - continue; - } - const normalizedTracker = normalizeForComparison(trimmedTracker); - if (!normalizedTracker || existingTrackers.has(normalizedTracker)) { - continue; - } - params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker }); - existingTrackers.add(normalizedTracker); - didMutate = true; + if ( + ensureWebSeeds(params, seedInputs, { + allowHttp: resolvedProtocol === "http:", + allowUnparsed: false, + logger, + }) + ) { + didChange = true; } - if (safeTorrentHint) { - const existingXs = new Set( - params - .filter((param) => param.key.toLowerCase() === "xs") - .map((param) => normalizeForComparison(param.decoded)) - .filter(Boolean) - ); - const normalizedXs = normalizeForComparison(safeTorrentHint); - if (normalizedXs && !existingXs.has(normalizedXs)) { - params.push({ - key: "xs", - value: safeTorrentHint, - decoded: safeTorrentHint, - }); - didMutate = true; - } - } - - if (seedInputs.length) { - const existingWs = new Set( - params - .filter((param) => param.key.toLowerCase() === "ws") - .map((param) => normalizeForComparison(param.decoded)) - .filter(Boolean) - ); - for (const seedInput of seedInputs) { - if (typeof seedInput !== "string") { - continue; - } - const trimmedSeed = seedInput.trim(); - if (!trimmedSeed) { - continue; - } - try { - const parsedSeed = new URL(trimmedSeed); - const seedProtocol = parsedSeed.protocol; - const allowHttpSeed = resolvedProtocol === "http:"; - if ( - seedProtocol === "https:" || - (seedProtocol === "http:" && allowHttpSeed) - ) { - const seedValue = parsedSeed.toString(); - const normalizedSeed = normalizeForComparison(seedValue); - if (normalizedSeed && !existingWs.has(normalizedSeed)) { - params.push({ key: "ws", value: seedValue, decoded: seedValue }); - existingWs.add(normalizedSeed); - didMutate = true; - } - } else if (seedProtocol === "http:") { - log( - `[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}` - ); - } - } catch (err) { - // Ignore invalid web seed values silently. - } - } - } - - const queryString = params - .map(({ key, value }) => (value ? `${key}=${value}` : key)) - .join("&"); - - const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; + const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment); return { magnet: finalMagnet, - didChange: didMutate || finalMagnet !== initial, + didChange: didChange || finalMagnet !== initial, }; } diff --git a/tests/magnet-utils.test.mjs b/tests/magnet-utils.test.mjs index 1297664c..bfadfc76 100644 --- a/tests/magnet-utils.test.mjs +++ b/tests/magnet-utils.test.mjs @@ -1,160 +1,205 @@ import assert from "node:assert/strict"; +import test from "node:test"; + import { WSS_TRACKERS } from "../js/constants.js"; -import { normalizeAndAugmentMagnet, safeDecodeMagnet } from "../js/magnetUtils.js"; -import { normalizeAndAugmentMagnet as formNormalize } from "../js/magnet.js"; +import { + normalizeAndAugmentMagnet as normalizeMagnetObject, + safeDecodeMagnet, +} from "../js/magnetUtils.js"; +import { + extractMagnetHints, + normalizeAndAugmentMagnet as normalizeMagnetString, +} from "../js/magnet.js"; function getParamValues(magnet, key) { const parsed = new URL(magnet); return parsed.searchParams.getAll(key); } -(function testBareInfoHashNormalization() { - const infoHash = "0123456789abcdef0123456789abcdef01234567"; - const result = normalizeAndAugmentMagnet(infoHash); - assert.ok(result.didChange, "Bare info hash should mark didChange true"); - const xtValues = getParamValues(result.magnet, "xt"); - assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); - const trackerValues = getParamValues(result.magnet, "tr"); +function assertDefaultTrackersPresent(magnet) { + const trackers = getParamValues(magnet, "tr"); for (const tracker of WSS_TRACKERS) { assert.ok( - trackerValues.includes(tracker), - `Expected tracker ${tracker} to be appended` + trackers.includes(tracker), + `Expected default tracker ${tracker} to be present` ); } -})(); +} -(function testEncodedXtDecoding() { - const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; - const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`; - const result = normalizeAndAugmentMagnet(encodedMagnet); - const xtValues = getParamValues(result.magnet, "xt"); - assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); -})(); - -(function testDuplicateTrackerFiltering() { - const infoHash = "fedcba9876543210fedcba9876543210fedcba98"; - const baseMagnet = - `magnet:?xt=urn:btih:${infoHash}` + - "&tr=wss://tracker.openwebtorrent.com" + - "&tr=WsS://tracker.openwebtorrent.com/" + - "&tr=https://not-allowed.example"; - const result = normalizeAndAugmentMagnet(baseMagnet, { - extraTrackers: [ - "wss://tracker.fastcast.nz", - "http://tracker.invalid", - "wss://tracker.openwebtorrent.com", - ], - }); - const trackers = getParamValues(result.magnet, "tr"); - const openwebCount = trackers.filter( - (value) => value === "wss://tracker.openwebtorrent.com" - ).length; - assert.equal(openwebCount, 1, "Duplicate WSS tracker should be collapsed"); - assert.ok( - trackers.includes("wss://tracker.fastcast.nz"), - "Expected extra WSS tracker to be preserved" - ); - assert.ok( - !trackers.includes("http://tracker.invalid"), - "Non-WSS tracker candidates should be ignored" - ); -})(); - -(function testWebSeedHandlingWithLogging() { - const infoHash = "0123456789abcdef0123456789abcdef01234567"; - const messages = []; - const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, { - webSeed: [ - "https://cdn.example.com/video.mp4", - "http://cdn.example.com/legacy.mp4", - "https://cdn.example.com/video.mp4", - ], - logger: (msg) => messages.push(msg), - appProtocol: "https:", - }); - const seeds = getParamValues(result.magnet, "ws"); - assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]); - assert.equal(messages.length, 1, "HTTP seed should have been skipped with a log"); - assert.ok( - messages[0].includes("Skipping insecure web seed"), - "Expected skip message for HTTP seed" - ); -})(); - -(function testHttpWebSeedAllowedOnHttpOrigin() { - const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; - const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, { - webSeed: "http://cdn.example.com/video.mp4", - appProtocol: "http:", - }); - const seeds = getParamValues(result.magnet, "ws"); - assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]); -})(); - -(function testSafeDecodeMagnetHandlesEncodedValues() { +test("safeDecodeMagnet handles encoded values", () => { const rawMagnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example"; const encodedMagnet = encodeURIComponent(rawMagnet); assert.equal( safeDecodeMagnet(encodedMagnet), rawMagnet, - "Expected percent-encoded magnet to be decoded" + "Percent-encoded magnets should be decoded" ); -})(); +}); -(function testSafeDecodeMagnetLeavesPlainStringsUntouched() { +test("safeDecodeMagnet leaves plain strings untouched", () => { const rawMagnet = "magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd"; assert.equal( safeDecodeMagnet(rawMagnet), rawMagnet, - "Plain magnet strings should pass through unchanged" + "Plain magnet strings should pass through" ); -})(); +}); -(function testFormHelperAugmentsWsXsAndDecodesXt() { - const infoHash = "abcdef0123456789abcdef0123456789abcdef01"; - const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}`; - const normalized = formNormalize(encodedMagnet, { - ws: "https://cdn.example.com/video-base/", +test("bare hashes normalize consistently across helpers", () => { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const objectResult = normalizeMagnetObject(infoHash); + const stringResult = normalizeMagnetString(infoHash); + + assert.ok(objectResult.didChange, "Bare hashes should mark didChange true"); + assert.equal( + stringResult, + objectResult.magnet, + "String helper should match object helper output" + ); + + const xtValues = getParamValues(stringResult, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); + assertDefaultTrackersPresent(stringResult); +}); + +test("legacy %3A payloads decode across helpers", () => { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const encoded = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`; + const objectResult = normalizeMagnetObject(encoded); + const stringResult = normalizeMagnetString(encoded); + + assert.equal( + stringResult, + objectResult.magnet, + "Helpers should agree on encoded xt payload normalization" + ); + + const xtValues = getParamValues(stringResult, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); +}); + +test("duplicate trackers and hints stay in sync", () => { + const infoHash = "fedcba9876543210fedcba9876543210fedcba98"; + const baseMagnet = + `magnet:?xt=urn:btih:${infoHash}` + + "&tr=wss://tracker.openwebtorrent.com" + + "&tr=WsS://tracker.openwebtorrent.com/"; + + const objectResult = normalizeMagnetObject(baseMagnet, { + extraTrackers: [ + "wss://tracker.fastcast.nz", + "http://tracker.invalid", + "wss://tracker.openwebtorrent.com", + ], + xs: "https://cdn.example.com/video.torrent", + webSeed: "https://cdn.example.com/files/", + }); + + const stringResult = normalizeMagnetString(baseMagnet, { + ws: "https://cdn.example.com/files/", // ensure string helper adds hint xs: "https://cdn.example.com/video.torrent", }); - const parsed = new URL(normalized); - assert.equal( - parsed.searchParams.get("xt"), - `urn:btih:${infoHash}`, - "Form helper should decode encoded xt payloads" - ); - assert.deepEqual( - parsed.searchParams.getAll("ws"), - ["https://cdn.example.com/video-base/"], - "Expected ws hint to be appended" - ); - assert.deepEqual( - parsed.searchParams.getAll("xs"), - ["https://cdn.example.com/video.torrent"], - "Expected xs hint to be appended" - ); - const trackers = parsed.searchParams.getAll("tr"); - for (const tracker of WSS_TRACKERS) { - assert.ok( - trackers.includes(tracker), - `Expected helper to append tracker ${tracker}` - ); - } -})(); -(function testFormHelperSkipsInsecureWsOnHttpsOrigin() { + assert.equal( + stringResult, + objectResult.magnet, + "Helpers should emit identical magnets after augmentation" + ); + + const trackers = getParamValues(stringResult, "tr"); + const openwebCount = trackers.filter( + (value) => value === "wss://tracker.openwebtorrent.com" + ).length; + assert.equal(openwebCount, 1, "Duplicate trackers should be deduped when appended"); + assert.ok( + trackers.includes("wss://tracker.fastcast.nz"), + "Extra WSS tracker should be retained" + ); + assert.ok( + !trackers.includes("http://tracker.invalid"), + "HTTP tracker candidates should be ignored" + ); + + const webSeeds = getParamValues(stringResult, "ws"); + assert.deepEqual(webSeeds, ["https://cdn.example.com/files/"]); + + const xsValues = getParamValues(stringResult, "xs"); + assert.deepEqual(xsValues, ["https://cdn.example.com/video.torrent"]); +}); + +test("object helper enforces HTTPS web seeds when on HTTPS", () => { const infoHash = "0123456789abcdef0123456789abcdef01234567"; - const normalized = formNormalize(`magnet:?xt=urn:btih:${infoHash}`, { + const messages = []; + const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: [ + "https://cdn.example.com/video.mp4", + "http://cdn.example.com/legacy.mp4", + ], + logger: (msg) => messages.push(msg), + appProtocol: "https:", + }); + + const seeds = getParamValues(result.magnet, "ws"); + assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]); + assert.equal(messages.length, 1, "HTTP web seeds should trigger a log message"); + assert.ok( + messages[0].includes("Skipping insecure web seed"), + "Expected skip log for HTTP seed" + ); +}); + +test("object helper allows HTTP seeds on HTTP origins", () => { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: "http://cdn.example.com/video.mp4", + appProtocol: "http:", + }); + + const seeds = getParamValues(result.magnet, "ws"); + assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]); +}); + +test("extractMagnetHints returns first ws/xs pair", () => { + const magnet = + "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01" + + "&ws=https://cdn.example.com/base/" + + "&ws=https://cdn.example.com/duplicate/" + + "&xs=https://cdn.example.com/video.torrent"; + + const hints = extractMagnetHints(magnet); + assert.deepEqual(hints, { + ws: "https://cdn.example.com/base/", + xs: "https://cdn.example.com/video.torrent", + }); +}); + +test("string helper skips insecure ws hints on HTTPS origins", () => { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const normalized = normalizeMagnetString(`magnet:?xt=urn:btih:${infoHash}`, { ws: "http://cdn.example.com/video-base/", }); const parsed = new URL(normalized); - assert.equal( - parsed.searchParams.getAll("ws").length, - 0, - "HTTP ws value should be skipped on HTTPS origins" - ); -})(); + assert.equal(parsed.searchParams.getAll("ws").length, 0); +}); -console.log("magnet-utils tests passed"); +test("object helper reports didChange when output differs", () => { + const infoHash = "abcdef0123456789abcdef0123456789abcdef01"; + const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: "https://cdn.example.com/video.mp4", + }); + assert.ok(result.didChange, "Augmenting with web seeds should mark didChange"); +}); + +test("helpers trim fragments from non-magnet values", () => { + const url = "https://example.com/video.mp4#fragment"; + const objectResult = normalizeMagnetObject(url); + const stringResult = normalizeMagnetString(url); + assert.equal(objectResult.magnet, "https://example.com/video.mp4"); + assert.equal(stringResult, "https://example.com/video.mp4"); + assert.ok( + objectResult.didChange, + "Dropping the fragment should be reflected in didChange" + ); +}); From bf638080075e281d2e99f5fe3874749f0cf23747 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:39:11 -0400 Subject: [PATCH 65/82] Refactor health observers to share card observer utility --- js/dom/cardObserver.js | 208 ++++++++++++++++++++++++++++++++++++++++ js/gridHealth.js | 104 +++++++++----------- js/urlHealthObserver.js | 84 ++++------------ 3 files changed, 272 insertions(+), 124 deletions(-) create mode 100644 js/dom/cardObserver.js diff --git a/js/dom/cardObserver.js b/js/dom/cardObserver.js new file mode 100644 index 00000000..53b6a1b6 --- /dev/null +++ b/js/dom/cardObserver.js @@ -0,0 +1,208 @@ +const DEFAULT_CARD_SELECTOR = ".video-card"; + +function defaultIsCardVisible(entry) { + if (!entry) { + return false; + } + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; +} + +function normalizeState(value) { + if (value && typeof value === "object") { + return value; + } + return {}; +} + +export function createCardObserver(options = {}) { + const { + rootMargin = "0px", + threshold = 0, + cardSelector = DEFAULT_CARD_SELECTOR, + isCardVisible = defaultIsCardVisible, + prepareEntries, + onCardVisible, + onCardRegister, + createState, + } = options; + + const containerState = new WeakMap(); + + function ensureState(container) { + let state = containerState.get(container); + if (state) { + return state; + } + + const observedCards = new WeakSet(); + const customState = normalizeState( + typeof createState === "function" ? createState(container) : {} + ); + + const stateWrapper = { + container, + observedCards, + customState, + observer: null, + }; + + const observer = new IntersectionObserver( + (entries) => { + handleEntries(entries, stateWrapper); + }, + { root: null, rootMargin, threshold } + ); + + stateWrapper.observer = observer; + containerState.set(container, stateWrapper); + return stateWrapper; + } + + function mapEntries(entries, state) { + if (!Array.isArray(entries) || entries.length === 0) { + return []; + } + + const context = { + container: state.container, + observer: state.observer, + state: state.customState, + }; + + if (typeof prepareEntries === "function") { + try { + const prepared = prepareEntries(entries, context); + if (Array.isArray(prepared)) { + return prepared; + } + } catch (err) { + console.warn("[cardObserver] prepareEntries failed", err); + } + } + + return entries; + } + + function handleEntries(entries, state) { + const mapped = mapEntries(entries, state); + if (!mapped.length) { + return; + } + + mapped.forEach((item) => { + let entry = item; + let card; + let meta; + + if (item && typeof item === "object" && "entry" in item) { + entry = item.entry; + card = item.card instanceof HTMLElement ? item.card : entry?.target; + if ("meta" in item) { + meta = item.meta; + } else if ("data" in item) { + meta = item.data; + } else if (typeof item.priority !== "undefined") { + meta = { priority: item.priority }; + } + } else if (entry && entry.target instanceof HTMLElement) { + card = entry.target; + } + + if (!(card instanceof HTMLElement) || !entry) { + return; + } + + let visible = false; + try { + visible = Boolean(isCardVisible(entry, card, { + container: state.container, + observer: state.observer, + state: state.customState, + })); + } catch (err) { + console.warn("[cardObserver] isCardVisible failed", err); + visible = false; + } + + if (!visible || typeof onCardVisible !== "function") { + return; + } + + try { + onCardVisible({ + card, + entry, + meta, + state: state.customState, + context: { + container: state.container, + observer: state.observer, + }, + }); + } catch (err) { + console.warn("[cardObserver] onCardVisible failed", err); + } + }); + } + + function observe(container) { + if (!(container instanceof HTMLElement)) { + return null; + } + + const state = ensureState(container); + const cards = container.querySelectorAll(cardSelector); + cards.forEach((card) => { + if (!(card instanceof HTMLElement)) { + return; + } + if (state.observedCards.has(card)) { + return; + } + state.observedCards.add(card); + if (typeof onCardRegister === "function") { + try { + onCardRegister({ + card, + state: state.customState, + context: { container }, + }); + } catch (err) { + console.warn("[cardObserver] onCardRegister failed", err); + } + } + state.observer.observe(card); + }); + + const records = state.observer.takeRecords(); + if (records.length) { + handleEntries(records, state); + } + + return state.customState; + } + + function refresh(container) { + const state = containerState.get(container); + if (!state) { + return; + } + const records = state.observer.takeRecords(); + if (records.length) { + handleEntries(records, state); + } + } + + function getState(container) { + const state = containerState.get(container); + return state ? state.customState : null; + } + + return { + observe, + refresh, + getState, + }; +} diff --git a/js/gridHealth.js b/js/gridHealth.js index 85a8821c..5c6c969c 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -1,7 +1,7 @@ +import { createCardObserver } from "./dom/cardObserver.js"; import { infoHashFromMagnet } from "./magnets.js"; import { TorrentClient, torrentClient } from "./webtorrent.js"; -const containerState = new WeakMap(); const ROOT_MARGIN = "200px 0px"; function now() { @@ -249,28 +249,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); const probeCache = new Map(); const probeInflight = new Map(); -function ensureState(container) { - let state = containerState.get(container); - if (state) { - return state; - } - - const pendingByCard = new WeakMap(); - const observedCards = new WeakSet(); - state = { observer: null, pendingByCard, observedCards }; - - const observer = new IntersectionObserver( - (entries) => { - processObserverEntries(entries, state); - }, - { root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 } - ); - - state.observer = observer; - containerState.set(container, state); - return state; -} - function getViewportCenter() { if (typeof window === "undefined") { return null; @@ -389,18 +367,6 @@ function prioritizeEntries(entries, viewportCenter) { })); } -function processObserverEntries(entries, state) { - if (!state || !entries || entries.length === 0) { - return; - } - const viewportCenter = getViewportCenter(); - const prioritized = prioritizeEntries(entries, viewportCenter); - prioritized.forEach(({ entry, priority }) => { - const card = entry.target; - handleCardVisible({ card, pendingByCard: state.pendingByCard, priority }); - }); -} - function getIntersectionRect(entry) { if (!entry) { return null; @@ -539,6 +505,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { return; } + if (!(pendingByCard instanceof WeakMap)) { + return; + } + if (pendingByCard.has(card)) { return; } @@ -590,36 +560,50 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { }); } +const gridCardObserver = createCardObserver({ + rootMargin: ROOT_MARGIN, + threshold: 0.01, + createState: () => ({ pendingByCard: new WeakMap() }), + prepareEntries: (entries) => { + const viewportCenter = getViewportCenter(); + return prioritizeEntries(entries, viewportCenter); + }, + isCardVisible: (entry) => { + if (!entry) { + return false; + } + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; + }, + onCardRegister: ({ card }) => { + if (!card.dataset.magnet) { + setBadge(card, "unhealthy", { reason: "missing-source" }); + } + }, + onCardVisible: ({ card, meta, state }) => { + if (!state || !(state.pendingByCard instanceof WeakMap)) { + return; + } + const priority = + meta && typeof meta === "object" && Number.isFinite(meta.priority) + ? meta.priority + : PRIORITY_BASELINE; + handleCardVisible({ + card, + pendingByCard: state.pendingByCard, + priority, + }); + }, +}); + export function attachHealthBadges(container) { if (!(container instanceof HTMLElement)) { return; } - const state = ensureState(container); - const cards = container.querySelectorAll(".video-card"); - cards.forEach((card) => { - if (!(card instanceof HTMLElement)) { - return; - } - if (state.observedCards.has(card)) { - return; - } - state.observedCards.add(card); - state.observer.observe(card); - if (!card.dataset.magnet) { - setBadge(card, "unhealthy", { reason: "missing-source" }); - } - }); - processObserverEntries(state.observer.takeRecords(), state); + gridCardObserver.observe(container); } export function refreshHealthBadges(container) { - if (!(container instanceof HTMLElement)) { - return; - } - const state = containerState.get(container); - if (!state) { - return; - } - const records = state.observer.takeRecords(); - processObserverEntries(records, state); + gridCardObserver.refresh(container); } diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index 05e16920..37410ebf 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -1,4 +1,5 @@ -const containerState = new WeakMap(); +import { createCardObserver } from "./dom/cardObserver.js"; + const ROOT_MARGIN = "0px"; const THRESHOLD = 0.25; @@ -13,41 +14,20 @@ function decodeUrl(value) { } } -function ensureState(container) { - let state = containerState.get(container); - if (state) { - return state; - } - - const observedCards = new WeakSet(); - const observer = new IntersectionObserver( - (entries) => { - processEntries(entries, state); - }, - { root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD } - ); - - state = { - observer, - observedCards, - onCheck: null, - }; - containerState.set(container, state); - return state; -} - -function processEntries(entries, state) { - if (!state?.onCheck || !Array.isArray(entries) || !entries.length) { - return; - } - - entries.forEach((entry) => { - if (!entry.isIntersecting || entry.intersectionRatio <= 0) { - return; +const urlCardObserver = createCardObserver({ + rootMargin: ROOT_MARGIN, + threshold: THRESHOLD, + isCardVisible: (entry) => { + if (!entry) { + return false; } - - const card = entry.target; - if (!(card instanceof HTMLElement)) { + const isIntersecting = Boolean(entry.isIntersecting); + const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + return isIntersecting && ratio > 0; + }, + createState: () => ({ onCheck: null }), + onCardVisible: ({ card, state }) => { + if (!state || typeof state.onCheck !== "function") { return; } @@ -81,44 +61,20 @@ function processEntries(entries, state) { } catch (err) { console.warn("[urlHealthObserver] onCheck handler failed", err); } - }); -} + }, +}); export function attachUrlHealthBadges(container, onCheck) { if (!(container instanceof HTMLElement)) { return; } - const state = ensureState(container); - if (typeof onCheck === "function") { + const state = urlCardObserver.observe(container); + if (state && typeof onCheck === "function") { state.onCheck = onCheck; } - - const cards = container.querySelectorAll(".video-card"); - cards.forEach((card) => { - if (!(card instanceof HTMLElement)) { - return; - } - if (state.observedCards.has(card)) { - return; - } - state.observedCards.add(card); - state.observer.observe(card); - }); - - const records = state.observer.takeRecords(); - if (records.length) { - processEntries(records, state); - } } export function refreshUrlHealthBadges(container) { - const state = containerState.get(container); - if (!state) { - return; - } - const records = state.observer.takeRecords(); - if (records.length) { - processEntries(records, state); - } + urlCardObserver.refresh(container); } From c2c7f6445dbc885ff264109b77fbab0f08b7a545 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:48:24 -0400 Subject: [PATCH 66/82] refactor: centralize safe decode helpers --- js/magnetShared.js | 33 +++++------------- js/urlHealthObserver.js | 14 ++------ js/utils/safeDecode.js | 32 +++++++++++++++++ js/webtorrent.js | 14 ++------ tests/safe-decode.test.mjs | 70 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 48 deletions(-) create mode 100644 js/utils/safeDecode.js create mode 100644 tests/safe-decode.test.mjs diff --git a/js/magnetShared.js b/js/magnetShared.js index a71f18fe..2d347cf6 100644 --- a/js/magnetShared.js +++ b/js/magnetShared.js @@ -1,25 +1,14 @@ import { WSS_TRACKERS } from "./constants.js"; +import { + safeDecodeURIComponent, + safeDecodeURIComponentLoose, +} from "./utils/safeDecode.js"; const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; const BTIH_PREFIX = "urn:btih:"; const MAGNET_SCHEME = "magnet:"; const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; -function decodeLoose(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - try { - return decodeURIComponent(trimmed); - } catch (err) { - return trimmed; - } -} - export function safeDecodeMagnet(value) { if (typeof value !== "string") { return ""; @@ -35,15 +24,11 @@ export function safeDecodeMagnet(value) { break; } - try { - const candidate = decodeURIComponent(decoded); - if (!candidate || candidate === decoded) { - break; - } - decoded = candidate.trim(); - } catch (err) { + const candidate = safeDecodeURIComponent(decoded); + if (!candidate || candidate === decoded) { break; } + decoded = candidate.trim(); } return decoded; @@ -73,7 +58,7 @@ export function normalizeForComparison(value) { function createParam(key, value) { const trimmedValue = typeof value === "string" ? value.trim() : ""; - const decoded = decodeLoose(trimmedValue); + const decoded = safeDecodeURIComponentLoose(trimmedValue); const comparisonBasis = decoded || trimmedValue; return { key, @@ -154,7 +139,7 @@ export function normalizeMagnetInput(rawValue) { const lowerKey = key.toLowerCase(); let value = rawVal.trim(); if (lowerKey === "xt" && value) { - const decoded = decodeLoose(value); + const decoded = safeDecodeURIComponentLoose(value); if (decoded && decoded !== value) { value = decoded; didMutate = true; diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index 37410ebf..c3746190 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -1,19 +1,9 @@ import { createCardObserver } from "./dom/cardObserver.js"; +import { safeDecodeURIComponent } from "./utils/safeDecode.js"; const ROOT_MARGIN = "0px"; const THRESHOLD = 0.25; -function decodeUrl(value) { - if (typeof value !== "string" || !value) { - return ""; - } - try { - return decodeURIComponent(value); - } catch (err) { - return value; - } -} - const urlCardObserver = createCardObserver({ rootMargin: ROOT_MARGIN, threshold: THRESHOLD, @@ -51,7 +41,7 @@ const urlCardObserver = createCardObserver({ return; } - const url = decodeUrl(encodedUrl); + const url = safeDecodeURIComponent(encodedUrl); if (!url) { return; } diff --git a/js/utils/safeDecode.js b/js/utils/safeDecode.js new file mode 100644 index 00000000..202b240d --- /dev/null +++ b/js/utils/safeDecode.js @@ -0,0 +1,32 @@ +export function safeDecodeURIComponent(value) { + if (typeof value !== "string") { + return ""; + } + + if (value === "") { + return ""; + } + + try { + return decodeURIComponent(value); + } catch (err) { + return value; + } +} + +export function safeDecodeURIComponentLoose(value, { trim = true } = {}) { + if (typeof value !== "string") { + return ""; + } + + const working = trim ? value.trim() : value; + if (trim && !working) { + return ""; + } + + try { + return decodeURIComponent(working); + } catch (err) { + return working; + } +} diff --git a/js/webtorrent.js b/js/webtorrent.js index c237558a..3b1d8cd1 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -2,20 +2,10 @@ import WebTorrent from "./webtorrent.min.js"; import { WSS_TRACKERS } from "./constants.js"; +import { safeDecodeURIComponent } from "./utils/safeDecode.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); -function decodeComponentSafe(value) { - if (typeof value !== "string") { - return ""; - } - try { - return decodeURIComponent(value); - } catch (err) { - return value; - } -} - function normalizeTrackerList(trackers) { const normalized = []; const seen = new Set(); @@ -73,7 +63,7 @@ function appendProbeTrackers(magnetURI, trackers) { if (!rawKey || rawKey.trim().toLowerCase() !== "tr") { return; } - const decoded = decodeComponentSafe(rawValue).trim().toLowerCase(); + const decoded = safeDecodeURIComponent(rawValue).trim().toLowerCase(); if (decoded) { trackerSet.add(decoded); } diff --git a/tests/safe-decode.test.mjs b/tests/safe-decode.test.mjs new file mode 100644 index 00000000..e6c424dc --- /dev/null +++ b/tests/safe-decode.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + safeDecodeURIComponent, + safeDecodeURIComponentLoose, +} from "../js/utils/safeDecode.js"; + +import { safeDecodeMagnet } from "../js/magnetUtils.js"; + +test("safeDecodeURIComponent returns original value on malformed sequences", () => { + const malformed = "%E0%A4%A"; + assert.equal( + safeDecodeURIComponent(malformed), + malformed, + "Malformed sequences should return the original input" + ); +}); + +test("safeDecode helpers handle double-encoded values when applied repeatedly", () => { + const raw = "magnet:?xt=urn:btih:abcdef"; + const doubleEncoded = encodeURIComponent(encodeURIComponent(raw)); + + const firstPass = safeDecodeURIComponent(doubleEncoded); + assert.equal( + firstPass, + encodeURIComponent(raw), + "First pass should peel one encoding layer" + ); + + const secondPass = safeDecodeURIComponent(firstPass); + assert.equal( + secondPass, + raw, + "Second pass should reveal the original string" + ); + + assert.equal( + safeDecodeMagnet(doubleEncoded), + raw, + "safeDecodeMagnet should continue to handle double-encoded magnets" + ); +}); + +test("safeDecodeURIComponentLoose trims inputs by default", () => { + const padded = " magnet:?dn=Example"; + assert.equal( + safeDecodeURIComponentLoose(padded), + "magnet:?dn=Example", + "Loose decoder should trim whitespace when decoding" + ); +}); + +test("safeDecodeURIComponentLoose preserves whitespace when trim is false", () => { + const padded = " magnet:?dn=Example"; + assert.equal( + safeDecodeURIComponentLoose(padded, { trim: false }), + padded, + "Loose decoder should return the original string when trim is disabled and decoding is unnecessary" + ); +}); + +test("safeDecodeURIComponent handles empty strings consistently", () => { + assert.equal(safeDecodeURIComponent(""), "", "Empty input should stay empty"); + assert.equal( + safeDecodeURIComponentLoose(""), + "", + "Loose decoder should return empty for empty input" + ); +}); From 15a5bbe499446e5b99afe9c2d6f797038fc5bf5e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:38:09 -0400 Subject: [PATCH 67/82] Implement watch history publishing and caching --- js/nostr.js | 1362 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1340 insertions(+), 22 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index c48c9989..c211b5a8 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -4,6 +4,14 @@ import { isDevMode, VIEW_COUNT_DEDUPE_WINDOW_SECONDS, VIEW_COUNT_BACKFILL_MAX_DAYS, + 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, } from "./config.js"; import { ACCEPT_LEGACY_V1, @@ -17,6 +25,8 @@ import { buildVideoPostEvent, buildVideoMirrorEvent, buildViewEvent, + buildWatchHistoryIndexEvent, + buildWatchHistoryChunkEvent, getNostrEventSchema, NOTE_TYPES, } from "./nostrEventSchemas.js"; @@ -24,6 +34,7 @@ import { publishEventToRelay, publishEventToRelays, assertAnyRelayAccepted, + summarizePublishResults, } from "./nostrPublish.js"; /** @@ -49,6 +60,13 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; +const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v2"; +const WATCH_HISTORY_STORAGE_VERSION = 2; +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 viewEventPublishMemory = new Map(); const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT); @@ -871,6 +889,135 @@ function chunkWatchHistoryPayloadItems(payloadItems, snapshotId, maxBytes) { return { chunks, skipped }; } +function normalizeActorKey(actor) { + if (typeof actor !== "string") { + return ""; + } + const trimmed = actor.trim(); + return trimmed ? 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) { + seen.set(key, pointer); + } + } + } + + 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; + }); + + if (!Number.isFinite(maxItems) || maxItems <= 0) { + return deduped; + } + + return deduped.slice(0, Math.max(0, Math.floor(maxItems))); +} + +function sanitizeWatchHistoryMetadata(metadata) { + if (!metadata || typeof metadata !== "object") { + return {}; + } + try { + return JSON.parse(JSON.stringify(metadata)); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to sanitize watch history metadata:", error); + } + 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 : ""; + 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; + } + return payload; + }); + + return JSON.stringify(normalized); +} + +function bytesToHex(bytes) { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function computeWatchHistoryFingerprintForItems(items) { + const serialized = serializeWatchHistoryItems(items); + 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) { + if (isDevMode) { + console.warn("[nostr] Failed to hash watch history fingerprint:", error); + } + } + } + + return `fallback:${serialized}`; +} + function eventToAddressPointer(event) { if (!event || typeof event !== "object") { return ""; @@ -1275,6 +1422,8 @@ class NostrClient { this.watchHistoryRepublishTimers = new Map(); this.watchHistoryRefreshPromises = new Map(); this.watchHistoryCacheTtlMs = 0; + this.watchHistoryFingerprints = new Map(); + this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); } @@ -1647,45 +1796,1203 @@ class NostrClient { this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays); } - /** - * Watch history has been removed from the client. All related helpers now - * return disabled responses so callers can gracefully no-op without touching - * storage or relays. - */ getWatchHistoryCacheTtlMs() { - return 0; + if (Number.isFinite(this.watchHistoryCacheTtlMs) && this.watchHistoryCacheTtlMs > 0) { + return this.watchHistoryCacheTtlMs; + } + + const configured = Number(WATCH_HISTORY_CACHE_TTL_MS); + const resolved = + Number.isFinite(configured) && configured > 0 + ? Math.floor(configured) + : 24 * 60 * 60 * 1000; + + this.watchHistoryCacheTtlMs = resolved; + return resolved; } getWatchHistoryStorage() { - return { version: 1, actors: {} }; + if (this.watchHistoryStorage && this.watchHistoryStorage.version === WATCH_HISTORY_STORAGE_VERSION) { + return this.watchHistoryStorage; + } + + const emptyStorage = { version: WATCH_HISTORY_STORAGE_VERSION, actors: {} }; + + if (typeof localStorage === "undefined") { + this.watchHistoryStorage = emptyStorage; + return this.watchHistoryStorage; + } + + let raw = null; + try { + raw = localStorage.getItem(WATCH_HISTORY_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to read watch history storage:", error); + } + this.watchHistoryStorage = emptyStorage; + return this.watchHistoryStorage; + } + + if (!raw || typeof raw !== "string") { + this.watchHistoryStorage = emptyStorage; + return this.watchHistoryStorage; + } + + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse watch history storage:", error); + } + this.watchHistoryStorage = emptyStorage; + return this.watchHistoryStorage; + } + + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + const actors = + parsed && typeof parsed === "object" && parsed.actors && typeof parsed.actors === "object" + ? parsed.actors + : {}; + + const sanitizedActors = {}; + let mutated = false; + + for (const [actorKeyRaw, entry] of Object.entries(actors)) { + const actorKey = normalizeActorKey(actorKeyRaw); + if (!actorKey) { + mutated = true; + continue; + } + + const savedAt = Number(entry?.savedAt); + if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) { + mutated = true; + continue; + } + + const items = Array.isArray(entry?.items) + ? canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + const metadata = sanitizeWatchHistoryMetadata(entry?.metadata); + const snapshotId = typeof entry?.snapshotId === "string" ? entry.snapshotId : ""; + const fingerprint = typeof entry?.fingerprint === "string" ? entry.fingerprint : ""; + + sanitizedActors[actorKey] = { + actor: + typeof entry?.actor === "string" && entry.actor.trim() + ? entry.actor.trim() + : actorKey, + snapshotId, + fingerprint, + savedAt, + items, + metadata, + }; + } + + const storage = { + version: WATCH_HISTORY_STORAGE_VERSION, + actors: sanitizedActors, + }; + + if (mutated || parsed?.version !== WATCH_HISTORY_STORAGE_VERSION) { + try { + localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storage)); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to rewrite watch history storage:", error); + } + } + } + + this.watchHistoryStorage = storage; + return this.watchHistoryStorage; } - persistWatchHistoryEntry() {} + persistWatchHistoryEntry(actorInput, entry) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return; + } - cancelWatchHistoryRepublish() {} + const storage = this.getWatchHistoryStorage(); + const actors = { ...storage.actors }; + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + let mutated = false; - scheduleWatchHistoryRepublish() {} + for (const [key, value] of Object.entries(actors)) { + const savedAt = Number(value?.savedAt); + if (!Number.isFinite(savedAt) || savedAt <= 0 || now - savedAt > ttl) { + delete actors[key]; + mutated = true; + } + } - ensureWatchHistoryBackgroundRefresh() {} + if (!entry) { + if (actors[actorKey]) { + delete actors[actorKey]; + mutated = true; + } + } else { + const items = Array.isArray(entry.items) + ? canonicalizeWatchHistoryItems(entry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + const metadata = sanitizeWatchHistoryMetadata(entry.metadata); + 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 : now; + const actorValue = + typeof entry.actor === "string" && entry.actor.trim() + ? entry.actor.trim() + : actorInput || actorKey; - async publishWatchHistorySnapshot() { - return { ok: false, error: "watch-history-disabled" }; + actors[actorKey] = { + actor: actorValue, + snapshotId, + fingerprint, + savedAt, + items, + metadata, + }; + mutated = true; + } + + const payload = { + version: WATCH_HISTORY_STORAGE_VERSION, + actors, + }; + + this.watchHistoryStorage = payload; + + if (!mutated || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to persist watch history entry:", error); + } + } } - async updateWatchHistoryList() { - return { ok: false, error: "watch-history-disabled" }; + cancelWatchHistoryRepublish(snapshotId = null) { + if (!snapshotId) { + for (const entry of this.watchHistoryRepublishTimers.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.watchHistoryRepublishTimers.clear(); + return; + } + + const key = typeof snapshotId === "string" ? snapshotId.trim() : ""; + if (!key) { + return; + } + + const entry = this.watchHistoryRepublishTimers.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.watchHistoryRepublishTimers.delete(key); } - async removeWatchHistoryItem() { - return { ok: false, error: "watch-history-disabled" }; + scheduleWatchHistoryRepublish(snapshotId, operation, options = {}) { + const key = typeof snapshotId === "string" ? snapshotId.trim() : ""; + if (!key || typeof operation !== "function") { + return; + } + + const previous = this.watchHistoryRepublishTimers.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; + } + + 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; + const delay = Math.max( + WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS, + Math.floor(cappedDelay + jitter), + ); + + const timer = setTimeout(async () => { + this.watchHistoryRepublishTimers.delete(key); + try { + const result = await operation(attempt + 1); + if (!result || result.ok !== true) { + if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) { + this.scheduleWatchHistoryRepublish(key, operation, { + attempt: attempt + 1, + }); + } else if (isDevMode) { + console.warn( + `[nostr] Watch history republish aborted for ${key}: max attempts reached.`, + ); + } + } else { + this.cancelWatchHistoryRepublish(key); + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Watch history republish attempt failed:", error); + } + if (attempt + 1 <= WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS) { + this.scheduleWatchHistoryRepublish(key, operation, { + attempt: attempt + 1, + }); + } + } + }, delay); + + this.watchHistoryRepublishTimers.set(key, { + timer, + attempt, + operation, + }); } - async fetchWatchHistory() { - return { pointerEvent: null, items: [] }; + async getWatchHistoryFingerprint(actorInput, itemsOverride = null) { + const resolvedActor = + typeof actorInput === "string" && actorInput.trim() + ? actorInput.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : ""; + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return ""; + } + + const items = Array.isArray(itemsOverride) + ? canonicalizeWatchHistoryItems(itemsOverride, WATCH_HISTORY_MAX_ITEMS) + : (() => { + const cacheEntry = + this.watchHistoryCache.get(actorKey) || + this.getWatchHistoryStorage().actors?.[actorKey]; + return Array.isArray(cacheEntry?.items) + ? canonicalizeWatchHistoryItems(cacheEntry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + })(); + + const fingerprint = await computeWatchHistoryFingerprintForItems(items); + const previous = this.watchHistoryFingerprints.get(actorKey); + if (previous && previous !== fingerprint) { + console.info(`[nostr] Watch history fingerprint changed for ${actorKey}.`); + } + this.watchHistoryFingerprints.set(actorKey, fingerprint); + return fingerprint; } - async resolveWatchHistory() { - return []; + ensureWatchHistoryBackgroundRefresh(actorInput = null) { + const resolvedActor = + typeof actorInput === "string" && actorInput.trim() + ? actorInput.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : this.sessionActor?.pubkey || ""; + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return Promise.resolve({ pointerEvent: null, items: [], snapshotId: "" }); + } + + if (this.watchHistoryRefreshPromises.has(actorKey)) { + return this.watchHistoryRefreshPromises.get(actorKey); + } + + const promise = (async () => { + const fetchResult = await this.fetchWatchHistory(resolvedActor, { + forceRefresh: true, + }); + + if (fetchResult.pointerEvent) { + return fetchResult; + } + + const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey]; + const metadata = sanitizeWatchHistoryMetadata(storageEntry?.metadata); + const alreadyAttempted = metadata.autoSnapshotAttempted === true; + + const items = Array.isArray(storageEntry?.items) + ? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + + if (!items.length || alreadyAttempted) { + return fetchResult; + } + + metadata.autoSnapshotAttempted = true; + metadata.autoSnapshotAttemptedAt = Date.now(); + + const publishResult = await this.publishWatchHistorySnapshot(items, { + actorPubkey: resolvedActor, + snapshotId: storageEntry?.snapshotId, + source: "background-refresh", + }); + + const fingerprint = await this.getWatchHistoryFingerprint(resolvedActor, items); + const entry = { + actor: resolvedActor, + items, + snapshotId: publishResult.snapshotId || storageEntry?.snapshotId || "", + pointerEvent: publishResult.pointerEvent || null, + chunkEvents: publishResult.chunkEvents || [], + savedAt: Date.now(), + fingerprint, + metadata, + }; + + this.watchHistoryCache.set(actorKey, entry); + this.persistWatchHistoryEntry(actorKey, entry); + + if (!publishResult.ok && publishResult.retryable) { + const retrySnapshot = entry.snapshotId || publishResult.snapshotId; + if (retrySnapshot) { + this.scheduleWatchHistoryRepublish(retrySnapshot, async (attempt) => + this.publishWatchHistorySnapshot(entry.items, { + actorPubkey: resolvedActor, + snapshotId: retrySnapshot, + attempt, + source: "background-refresh", + }), + ); + } + } else if (publishResult.ok && entry.snapshotId) { + this.cancelWatchHistoryRepublish(entry.snapshotId); + } + + return { + pointerEvent: entry.pointerEvent, + items: entry.items, + snapshotId: entry.snapshotId, + }; + })() + .catch((error) => { + if (isDevMode) { + console.warn("[nostr] Watch history background refresh failed:", error); + } + throw error; + }) + .finally(() => { + this.watchHistoryRefreshPromises.delete(actorKey); + }); + + this.watchHistoryRefreshPromises.set(actorKey, promise); + return promise; + } + + async publishWatchHistorySnapshot(rawItems, options = {}) { + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized", retryable: false }; + } + + const resolvedActor = + typeof options.actorPubkey === "string" && options.actorPubkey.trim() + ? options.actorPubkey.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : await this.ensureSessionActor(); + + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return { ok: false, error: "missing-actor", retryable: false }; + } + + const actorPubkey = resolvedActor || actorKey; + const extension = window?.nostr; + const extensionActorKey = normalizeActorKey(this.pubkey); + const canUseExtensionSign = + extension && + typeof extension.signEvent === "function" && + actorKey === extensionActorKey; + + const useExtensionEncrypt = + canUseExtensionSign && + extension && + extension.nip04 && + typeof extension.nip04.encrypt === "function"; + + let privateKey = ""; + if (!canUseExtensionSign) { + if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { + const ensured = await this.ensureSessionActor(); + if (normalizeActorKey(ensured) !== actorKey) { + return { ok: false, error: "session-actor-mismatch", retryable: false }; + } + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { + return { ok: false, error: "session-actor-missing", retryable: false }; + } + privateKey = this.sessionActor.privateKey; + if (!privateKey) { + return { ok: false, error: "missing-session-key", retryable: false }; + } + } + + const canonicalItems = canonicalizeWatchHistoryItems( + Array.isArray(rawItems) ? rawItems : [], + WATCH_HISTORY_MAX_ITEMS, + ); + + const snapshotId = + typeof options.snapshotId === "string" && options.snapshotId.trim() + ? options.snapshotId.trim() + : `snapshot-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; + + const { chunks, skipped } = chunkWatchHistoryPayloadItems( + canonicalItems, + snapshotId, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + ); + + if (skipped.length) { + console.warn( + `[nostr] Watch history snapshot skipped ${skipped.length} oversize entr${ + skipped.length === 1 ? "y" : "ies" + }.`, + ); + } + + let relays = sanitizeRelayList( + Array.isArray(options.relays) && options.relays.length + ? options.relays + : Array.isArray(this.writeRelays) && this.writeRelays.length + ? this.writeRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + if (!Array.isArray(relays) || relays.length === 0) { + relays = Array.from(RELAY_URLS); + } + + const createdAtBase = Math.max( + Math.floor(Date.now() / 1000), + this.watchHistoryLastCreatedAt + 1, + ); + + const encryptChunk = async (plaintext) => { + if (useExtensionEncrypt) { + return extension.nip04.encrypt(actorPubkey, plaintext); + } + if (!window?.NostrTools?.nip04?.encrypt) { + throw new Error("nip04-unavailable"); + } + return window.NostrTools.nip04.encrypt(privateKey, actorPubkey, plaintext); + }; + + const signEvent = async (event) => { + if (canUseExtensionSign) { + return extension.signEvent(event); + } + return signEventWithPrivateKey(event, privateKey); + }; + + let createdAtCursor = createdAtBase; + const chunkResults = []; + const chunkAddresses = []; + let anyChunkRejected = false; + + for (let index = 0; index < chunks.length; index += 1) { + const chunkItems = Array.isArray(chunks[index]) ? chunks[index] : []; + const pointerTags = chunkItems.map((pointer) => { + const tag = [pointer.type === "a" ? "a" : "e", pointer.value]; + if (pointer.relay) { + tag.push(pointer.relay); + } + return tag; + }); + + const plaintext = JSON.stringify({ + version: 2, + snapshot: snapshotId, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }); + + let ciphertext = ""; + try { + ciphertext = await encryptChunk(plaintext); + } catch (error) { + console.warn("[nostr] Failed to encrypt watch history chunk:", error); + return { ok: false, error: "encryption-failed", retryable: false }; + } + + const chunkIdentifier = `${snapshotId}:${index}`; + const event = buildWatchHistoryChunkEvent({ + pubkey: actorPubkey, + created_at: createdAtCursor, + chunkIdentifier, + snapshotId, + chunkIndex: index, + totalChunks: chunks.length, + pointerTags, + content: ciphertext, + }); + + createdAtCursor += 1; + + let signedEvent; + try { + signedEvent = await signEvent(event); + } catch (error) { + console.warn("[nostr] Failed to sign watch history chunk:", error); + return { ok: false, error: "signing-failed", retryable: false }; + } + + const publishResults = await publishEventToRelays(this.pool, relays, signedEvent); + const summary = summarizePublishResults(publishResults); + const acceptedCount = summary.accepted.length; + + if (acceptedCount === 0) { + anyChunkRejected = true; + console.warn( + `[nostr] Watch history chunk ${index} rejected by all relays:`, + publishResults, + ); + } else { + console.info( + `[nostr] Watch history chunk ${index} accepted by ${acceptedCount}/${relays.length} relay(s).`, + ); + } + + const address = eventToAddressPointer(signedEvent); + if (address) { + chunkAddresses.push(address); + } + + chunkResults.push({ + event: signedEvent, + publishResults, + acceptedCount, + }); + } + + const pointerEvent = buildWatchHistoryIndexEvent({ + pubkey: actorPubkey, + created_at: createdAtCursor, + snapshotId, + totalChunks: chunks.length, + chunkAddresses, + }); + createdAtCursor += 1; + + let signedPointerEvent; + try { + signedPointerEvent = await signEvent(pointerEvent); + } catch (error) { + console.warn("[nostr] Failed to sign watch history pointer event:", error); + return { ok: false, error: "signing-failed", retryable: false }; + } + + const pointerResults = await publishEventToRelays(this.pool, relays, signedPointerEvent); + const pointerSummary = summarizePublishResults(pointerResults); + const pointerAccepted = pointerSummary.accepted.length > 0; + + if (pointerAccepted) { + console.info( + `[nostr] Watch history pointer accepted by ${pointerSummary.accepted.length}/${relays.length} relay(s).`, + ); + } else { + console.warn("[nostr] Watch history pointer rejected by all relays:", pointerResults); + } + + this.watchHistoryLastCreatedAt = createdAtCursor; + + const success = pointerAccepted && !anyChunkRejected; + + return { + ok: success, + retryable: !success, + actor: actorPubkey, + snapshotId, + items: canonicalItems, + pointerEvent: signedPointerEvent, + chunkEvents: chunkResults.map((entry) => entry.event), + publishResults: { + pointer: pointerResults, + chunks: chunkResults.map((entry) => entry.publishResults), + }, + skippedCount: skipped.length, + source: options.source || "manual", + }; + } + + async updateWatchHistoryList(rawItems = [], options = {}) { + const resolvedActor = + typeof options.actorPubkey === "string" && options.actorPubkey.trim() + ? options.actorPubkey.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : await this.ensureSessionActor(); + + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return { ok: false, error: "missing-actor" }; + } + + const storage = this.getWatchHistoryStorage(); + const cachedEntry = + this.watchHistoryCache.get(actorKey) || storage.actors?.[actorKey] || {}; + + const existingItems = Array.isArray(cachedEntry.items) + ? canonicalizeWatchHistoryItems(cachedEntry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + const incomingItems = Array.isArray(rawItems) ? rawItems : []; + + const combined = + options.replace === true + ? incomingItems + : [...incomingItems, ...existingItems]; + + const canonicalItems = canonicalizeWatchHistoryItems( + combined, + WATCH_HISTORY_MAX_ITEMS, + ); + + const fingerprint = await this.getWatchHistoryFingerprint( + resolvedActor, + canonicalItems, + ); + + const publishResult = await this.publishWatchHistorySnapshot( + canonicalItems, + { + actorPubkey: resolvedActor, + snapshotId: options.snapshotId || cachedEntry.snapshotId, + attempt: options.attempt || 0, + }, + ); + + const metadata = sanitizeWatchHistoryMetadata(cachedEntry.metadata); + metadata.updatedAt = Date.now(); + metadata.status = publishResult.ok ? "ok" : "error"; + metadata.lastPublishResults = publishResult.publishResults; + metadata.skippedCount = publishResult.skippedCount || 0; + if (!publishResult.ok) { + metadata.lastError = publishResult.error || "publish-failed"; + } else { + delete metadata.lastError; + } + + const entry = { + actor: resolvedActor, + items: canonicalItems, + snapshotId: publishResult.snapshotId || cachedEntry.snapshotId || "", + pointerEvent: publishResult.pointerEvent || cachedEntry.pointerEvent || null, + chunkEvents: publishResult.chunkEvents || cachedEntry.chunkEvents || [], + savedAt: Date.now(), + fingerprint, + metadata, + }; + + this.watchHistoryCache.set(actorKey, entry); + this.persistWatchHistoryEntry(actorKey, entry); + + if (!publishResult.ok && publishResult.retryable && entry.snapshotId) { + this.scheduleWatchHistoryRepublish(entry.snapshotId, async (attempt) => + this.publishWatchHistorySnapshot(entry.items, { + actorPubkey: resolvedActor, + snapshotId: entry.snapshotId, + attempt, + }), + ); + } else if (publishResult.ok && entry.snapshotId) { + this.cancelWatchHistoryRepublish(entry.snapshotId); + } + + return publishResult; + } + + async removeWatchHistoryItem(pointerInput, options = {}) { + const pointer = normalizePointerInput(pointerInput); + if (!pointer) { + return { ok: false, error: "invalid-pointer" }; + } + + const resolvedActor = + typeof options.actorPubkey === "string" && options.actorPubkey.trim() + ? options.actorPubkey.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : await this.ensureSessionActor(); + + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return { ok: false, error: "missing-actor" }; + } + + const existingEntry = + this.watchHistoryCache.get(actorKey) || + this.getWatchHistoryStorage().actors?.[actorKey] || + {}; + const existingItems = Array.isArray(existingEntry.items) + ? canonicalizeWatchHistoryItems(existingEntry.items, WATCH_HISTORY_MAX_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.updateWatchHistoryList(filtered, { + ...options, + actorPubkey: resolvedActor, + replace: true, + }); + } + + async fetchWatchHistory(actorInput, options = {}) { + const resolvedActor = + typeof actorInput === "string" && actorInput.trim() + ? actorInput.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : this.sessionActor?.pubkey || ""; + + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return { pointerEvent: null, items: [], snapshotId: "" }; + } + + const extension = window?.nostr; + + const existingEntry = this.watchHistoryCache.get(actorKey); + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + + if ( + !options.forceRefresh && + existingEntry && + Number.isFinite(existingEntry.savedAt) && + now - existingEntry.savedAt < ttl + ) { + return { + pointerEvent: existingEntry.pointerEvent || null, + items: existingEntry.items || [], + snapshotId: existingEntry.snapshotId || "", + }; + } + + if (!this.pool) { + return { + pointerEvent: existingEntry?.pointerEvent || null, + items: existingEntry?.items || [], + snapshotId: existingEntry?.snapshotId || "", + }; + } + + const identifiers = [ + WATCH_HISTORY_LIST_IDENTIFIER, + ...WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS, + ]; + const limitRaw = Number(WATCH_HISTORY_FETCH_EVENT_LIMIT); + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 20; + + let readRelays = sanitizeRelayList( + Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + if (!Array.isArray(readRelays) || readRelays.length === 0) { + readRelays = Array.from(RELAY_URLS); + } + + let pointerEvents = []; + try { + const filters = [ + { + kinds: [WATCH_HISTORY_KIND], + authors: [resolvedActor], + "#d": identifiers, + limit, + }, + ]; + const results = await this.pool.list(readRelays, filters); + pointerEvents = Array.isArray(results) + ? results + .flat() + .filter((event) => event && typeof event === "object") + : []; + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to fetch watch history pointer:", error); + } + } + + const pointerEvent = pointerEvents.reduce((latest, current) => { + if (!current || typeof current !== "object") { + return latest; + } + const currentCreated = Number.isFinite(current.created_at) + ? current.created_at + : 0; + const latestCreated = Number.isFinite(latest?.created_at) + ? latest.created_at + : 0; + if (currentCreated > latestCreated) { + return current; + } + return latest; + }, null); + + if (!pointerEvent) { + const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey]; + const items = Array.isArray(storageEntry?.items) + ? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS) + : []; + const fingerprint = typeof storageEntry?.fingerprint === "string" + ? storageEntry.fingerprint + : await this.getWatchHistoryFingerprint(resolvedActor, items); + const entry = { + actor: resolvedActor, + items, + snapshotId: typeof storageEntry?.snapshotId === "string" + ? storageEntry.snapshotId + : "", + pointerEvent: null, + chunkEvents: [], + savedAt: now, + fingerprint, + metadata: sanitizeWatchHistoryMetadata(storageEntry?.metadata), + }; + this.watchHistoryCache.set(actorKey, entry); + this.persistWatchHistoryEntry(actorKey, entry); + return { pointerEvent: null, items, snapshotId: entry.snapshotId }; + } + + const fallbackItems = extractPointerItemsFromEvent(pointerEvent); + const pointerPayload = parseWatchHistoryContentWithFallback( + pointerEvent.content, + fallbackItems, + { + version: 0, + items: fallbackItems, + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }, + ); + + const snapshotId = (() => { + const tags = Array.isArray(pointerEvent.tags) ? pointerEvent.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "snapshot" && typeof tag[1] === "string") { + return tag[1]; + } + } + return pointerPayload.snapshot || ""; + })(); + + const chunkAddresses = (() => { + const tags = Array.isArray(pointerEvent.tags) ? pointerEvent.tags : []; + const addresses = []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "a" && typeof tag[1] === "string" && tag[1]) { + addresses.push(tag[1]); + } + } + return addresses; + })(); + + const chunkIdentifiers = []; + for (const address of chunkAddresses) { + const parts = address.split(":"); + if (parts.length >= 3) { + const identifier = parts.slice(2).join(":"); + if (identifier) { + chunkIdentifiers.push(identifier); + } + } + } + + const chunkFilters = []; + if (chunkIdentifiers.length) { + chunkFilters.push({ + kinds: [WATCH_HISTORY_KIND], + authors: [resolvedActor], + "#d": chunkIdentifiers, + limit: Math.max(chunkIdentifiers.length * 2, limit), + }); + } else if (snapshotId) { + chunkFilters.push({ + kinds: [WATCH_HISTORY_KIND], + authors: [resolvedActor], + "#snapshot": [snapshotId], + limit, + }); + } + + let chunkEvents = []; + if (chunkFilters.length) { + try { + const results = await this.pool.list(readRelays, chunkFilters); + chunkEvents = Array.isArray(results) + ? results + .flat() + .filter((event) => event && typeof event === "object") + : []; + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to fetch watch history chunks:", error); + } + } + } + + const latestChunks = new Map(); + for (const event of chunkEvents) { + if (!event || typeof event !== "object") { + continue; + } + const tags = Array.isArray(event.tags) ? event.tags : []; + let identifier = ""; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") { + identifier = tag[1]; + break; + } + } + if (!identifier) { + continue; + } + const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0; + const existing = latestChunks.get(identifier); + if (!existing || createdAt > existing.created_at) { + latestChunks.set(identifier, event); + } + } + + const decryptErrors = []; + const collectedItems = []; + + const decryptChunk = async (ciphertext) => { + if (!ciphertext || typeof ciphertext !== "string") { + throw new Error("empty-ciphertext"); + } + const extensionDecrypt = + extension && + typeof extension?.nip04?.decrypt === "function" && + normalizeActorKey(this.pubkey) === actorKey; + if (extensionDecrypt) { + return extension.nip04.decrypt(resolvedActor, ciphertext); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { + await this.ensureSessionActor(); + } + if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { + throw new Error("missing-session-key"); + } + if (!window?.NostrTools?.nip04?.decrypt) { + throw new Error("nip04-unavailable"); + } + return window.NostrTools.nip04.decrypt( + this.sessionActor.privateKey, + resolvedActor, + ciphertext, + ); + }; + + const chunkCount = latestChunks.size || chunkIdentifiers.length || 0; + const chunkKeys = chunkIdentifiers.length + ? chunkIdentifiers + : Array.from(latestChunks.keys()); + + for (const identifier of chunkKeys) { + const event = latestChunks.get(identifier); + if (!event) { + continue; + } + const fallbackPointers = extractPointerItemsFromEvent(event); + const ciphertext = typeof event.content === "string" ? event.content : ""; + let payload; + if (isNip04EncryptedWatchHistoryEvent(event, ciphertext)) { + try { + const plaintext = await decryptChunk(ciphertext); + payload = parseWatchHistoryContentWithFallback( + plaintext, + fallbackPointers, + { + version: 0, + items: fallbackPointers, + snapshot: snapshotId, + chunkIndex: 0, + totalChunks: chunkCount || 1, + }, + ); + } catch (error) { + decryptErrors.push(error); + payload = { + version: 0, + items: fallbackPointers, + }; + } + } else { + payload = parseWatchHistoryContentWithFallback( + ciphertext, + fallbackPointers, + { + version: 0, + items: fallbackPointers, + snapshot: snapshotId, + chunkIndex: 0, + totalChunks: chunkCount || 1, + }, + ); + } + if (Array.isArray(payload?.items)) { + collectedItems.push(...payload.items); + } + } + + if (decryptErrors.length) { + console.warn( + `[nostr] Failed to decrypt ${decryptErrors.length} watch history chunk(s) for ${actorKey}. Using fallback pointers.`, + ); + } + + const mergedItems = collectedItems.length ? collectedItems : pointerPayload.items; + const canonicalItems = canonicalizeWatchHistoryItems( + mergedItems, + WATCH_HISTORY_MAX_ITEMS, + ); + + const fingerprint = await this.getWatchHistoryFingerprint( + resolvedActor, + canonicalItems, + ); + + const metadata = sanitizeWatchHistoryMetadata( + this.getWatchHistoryStorage().actors?.[actorKey]?.metadata, + ); + metadata.lastFetchedAt = now; + metadata.decryptErrors = decryptErrors.length; + + const entry = { + actor: resolvedActor, + items: canonicalItems, + snapshotId, + pointerEvent, + chunkEvents: Array.from(latestChunks.values()), + savedAt: now, + fingerprint, + metadata, + }; + + this.watchHistoryCache.set(actorKey, entry); + this.persistWatchHistoryEntry(actorKey, entry); + + return { pointerEvent, items: canonicalItems, snapshotId }; + } + + async resolveWatchHistory(actorInput, options = {}) { + const resolvedActor = + typeof actorInput === "string" && actorInput.trim() + ? actorInput.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : await this.ensureSessionActor(); + + const actorKey = normalizeActorKey(resolvedActor); + if (!actorKey) { + return []; + } + + const storage = this.getWatchHistoryStorage(); + const fallbackItems = Array.isArray(storage.actors?.[actorKey]?.items) + ? canonicalizeWatchHistoryItems( + storage.actors[actorKey].items, + WATCH_HISTORY_MAX_ITEMS, + ) + : []; + + const fetchResult = await this.fetchWatchHistory(resolvedActor, { + forceRefresh: options.forceRefresh || false, + }); + + const merged = mergeWatchHistoryItemsWithFallback( + { + version: 2, + items: fetchResult.items || [], + snapshot: fetchResult.snapshotId || "", + chunkIndex: 0, + totalChunks: Array.isArray(fetchResult.items) + ? fetchResult.items.length + : 0, + }, + fallbackItems, + ); + + const batchLimitRaw = Number(WATCH_HISTORY_BATCH_RESOLVE); + const batchLimit = Number.isFinite(batchLimitRaw) && batchLimitRaw > 0 + ? Math.min(Math.floor(batchLimitRaw), WATCH_HISTORY_MAX_ITEMS) + : WATCH_HISTORY_MAX_ITEMS; + + const canonicalItems = canonicalizeWatchHistoryItems( + merged.items || [], + batchLimit, + ); + + const fingerprint = await this.getWatchHistoryFingerprint( + resolvedActor, + canonicalItems, + ); + + const entry = { + actor: resolvedActor, + items: canonicalItems, + snapshotId: fetchResult.snapshotId || storage.actors?.[actorKey]?.snapshotId || "", + pointerEvent: fetchResult.pointerEvent || null, + chunkEvents: [], + savedAt: Date.now(), + fingerprint, + metadata: sanitizeWatchHistoryMetadata(storage.actors?.[actorKey]?.metadata), + }; + + this.watchHistoryCache.set(actorKey, entry); + this.persistWatchHistoryEntry(actorKey, entry); + + return canonicalItems; } async listVideoViewEvents(pointer, options = {}) { @@ -2382,9 +3689,20 @@ class NostrClient { logout() { this.pubkey = null; for (const timer of this.watchHistoryRepublishTimers.values()) { - clearTimeout(timer); + 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.watchHistoryRepublishTimers.clear(); + this.watchHistoryCache.clear(); + this.watchHistoryFingerprints.clear(); + this.watchHistoryRefreshPromises.clear(); + this.watchHistoryLastCreatedAt = 0; + this.watchHistoryStorage = null; if (isDevMode) console.log("User logged out."); } From 97a8ff814efe39d4f3274630794a483e326c0275 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:58:42 -0400 Subject: [PATCH 68/82] feat: add watch history service and integrate playback flush --- js/app.js | 66 +++- js/constants.js | 28 ++ js/nostr.js | 6 + js/watchHistoryService.js | 776 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 js/watchHistoryService.js diff --git a/js/app.js b/js/app.js index cdc147a9..1026ad4d 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ // js/app.js import { loadView } from "./viewManager.js"; -import { nostrClient, recordVideoView } from "./nostr.js"; +import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; import { accessControl, normalizeNpub } from "./accessControl.js"; @@ -15,6 +15,7 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; +import watchHistoryService from "./watchHistoryService.js"; import { loadR2Settings, saveR2Settings, @@ -5968,11 +5969,31 @@ class bitvidApp { // 7) Cleanup on page unload window.addEventListener("beforeunload", () => { + this.flushWatchHistory("session-end", "beforeunload").catch((error) => { + if (isDevMode) { + console.warn("[beforeunload] Watch history flush failed:", error); + } + }); this.cleanup().catch((err) => { console.error("Cleanup before unload failed:", err); }); }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + this.flushWatchHistory("session-end", "visibilitychange").catch( + (error) => { + if (isDevMode) { + console.warn( + "[visibilitychange] Watch history flush failed:", + error + ); + } + } + ); + } + }); + // 8) Handle back/forward navigation => hide video modal window.addEventListener("popstate", async () => { console.log("[popstate] user navigated back/forward; cleaning modal..."); @@ -6846,6 +6867,15 @@ class bitvidApp { ); try { this.cancelPendingViewLogging(); + await this.flushWatchHistory("session-end", "cleanup").catch( + (error) => { + const message = + error && typeof error.message === "string" + ? error.message + : String(error ?? "unknown error"); + this.log(`[cleanup] Watch history flush failed: ${message}`); + } + ); this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); this.teardownModalViewCountSubscription(); @@ -6976,6 +7006,18 @@ class bitvidApp { } this.playbackTelemetryState = null; + + this.flushWatchHistory("session-end", "cancelPendingViewLogging").catch( + (error) => { + const message = + error && typeof error.message === "string" + ? error.message + : String(error ?? "unknown error"); + this.log( + `[cancelPendingViewLogging] Watch history flush failed: ${message}` + ); + } + ); } resetViewLoggingState() { @@ -6985,6 +7027,26 @@ class bitvidApp { } } + flushWatchHistory(reason = "session-end", context = "watch-history") { + if (!watchHistoryService?.isEnabled?.()) { + return Promise.resolve(); + } + try { + const result = watchHistoryService.snapshot(undefined, { reason }); + return Promise.resolve(result).catch((error) => { + if (isDevMode) { + console.warn(`[${context}] Watch history flush failed:`, error); + } + throw error; + }); + } catch (error) { + if (isDevMode) { + console.warn(`[${context}] Failed to queue watch history flush:`, error); + } + return Promise.reject(error); + } + } + getActiveViewIdentityKey() { const normalizedUser = this.normalizeHexPubkey(this.pubkey); if (normalizedUser) { @@ -7086,7 +7148,7 @@ class bitvidApp { (async () => { let viewResult; try { - viewResult = await recordVideoView(thresholdPointer); + viewResult = await watchHistoryService.publishView(thresholdPointer); } catch (error) { if (isDevMode) { console.warn( diff --git a/js/constants.js b/js/constants.js index 19780950..831d3e3d 100644 --- a/js/constants.js +++ b/js/constants.js @@ -2,6 +2,7 @@ const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: true, // try URL before magnet in the player ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, + FEATURE_WATCH_HISTORY_V2: false, WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", "wss://tracker.fastcast.nz", @@ -21,6 +22,7 @@ const runtimeFlags = (() => { ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1, VIEW_FILTER_INCLUDE_LEGACY_VIDEO: DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, + FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], }; if (globalScope) { @@ -101,6 +103,11 @@ export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean( DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO ); +export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean( + runtimeFlags.FEATURE_WATCH_HISTORY_V2, + DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2 +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS) ); @@ -141,6 +148,20 @@ Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", { }, }); +Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", { + configurable: true, + enumerable: true, + get() { + return FEATURE_WATCH_HISTORY_V2; + }, + set(next) { + FEATURE_WATCH_HISTORY_V2 = coerceBoolean( + next, + DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2 + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -156,6 +177,7 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED; runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1; runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO; +runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; export function setUrlFirstEnabled(next) { @@ -184,8 +206,14 @@ export function resetRuntimeFlags() { setViewFilterIncludeLegacyVideo( DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO ); + setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2); setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS); } export const RUNTIME_FLAGS = runtimeFlags; +export function setWatchHistoryV2Enabled(next) { + runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next; + return FEATURE_WATCH_HISTORY_V2; +} + diff --git a/js/nostr.js b/js/nostr.js index c211b5a8..0d46db7e 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -265,6 +265,10 @@ function clonePointerItem(pointer) { cloned.watchedAt = watchedAt; } + if (pointer.session === true) { + cloned.session = true; + } + return cloned; } @@ -5312,3 +5316,5 @@ export const countVideoViewEvents = (...args) => { } return nostrClient.countVideoViewEvents(...args); }; + +export { normalizePointerInput, pointerKey, chunkWatchHistoryPayloadItems, normalizeActorKey }; diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js new file mode 100644 index 00000000..6b9605de --- /dev/null +++ b/js/watchHistoryService.js @@ -0,0 +1,776 @@ +// js/watchHistoryService.js + +import { + nostrClient, + normalizePointerInput, + pointerKey, + normalizeActorKey, +} from "./nostr.js"; +import { + WATCH_HISTORY_CACHE_TTL_MS, + WATCH_HISTORY_MAX_ITEMS, +} from "./config.js"; +import { FEATURE_WATCH_HISTORY_V2 } from "./constants.js"; +import { isDevMode } from "./config.js"; + +const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1"; +const SESSION_STORAGE_VERSION = 1; +const POINTER_THROTTLE_MS = 60 * 1000; + +const state = { + restored: false, + queues: new Map(), + inflightSnapshots: new Map(), + fingerprintCache: new Map(), + listeners: new Map(), +}; + +function isFeatureEnabled() { + return FEATURE_WATCH_HISTORY_V2 === true; +} + +function getSessionStorage() { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + return window.sessionStorage; + } + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] sessionStorage unavailable:", error); + } + } + return null; +} + +function emit(eventName, payload) { + const listeners = state.listeners.get(eventName); + if (listeners && listeners.size) { + for (const callback of Array.from(listeners)) { + try { + callback(payload); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Listener for ${eventName} failed:`, + error + ); + } + } + } + } + + const wildcard = state.listeners.get("*"); + if (wildcard && wildcard.size) { + for (const callback of Array.from(wildcard)) { + try { + callback({ event: eventName, payload }); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Wildcard listener error for ${eventName}:`, + error + ); + } + } + } + } + + if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { + try { + const detail = { event: eventName, payload }; + const evt = new CustomEvent("bitvid:watchHistory", { detail }); + window.dispatchEvent(evt); + } catch (error) { + if (isDevMode) { + console.warn( + `[watchHistoryService] Failed to dispatch window event for ${eventName}:`, + error + ); + } + } + } +} + +function subscribe(eventName, callback) { + if (typeof callback !== "function") { + return () => {}; + } + if (!state.listeners.has(eventName)) { + state.listeners.set(eventName, new Set()); + } + const listeners = state.listeners.get(eventName); + listeners.add(callback); + return () => { + listeners.delete(callback); + if (!listeners.size) { + state.listeners.delete(eventName); + } + }; +} + +function getOrCreateQueue(actorKey) { + let queue = state.queues.get(actorKey); + if (!queue) { + queue = { + items: new Map(), + throttle: new Map(), + pendingSnapshotId: null, + lastSnapshotReason: null, + republishScheduled: false, + }; + state.queues.set(actorKey, queue); + } + return queue; +} + +function restoreQueueState() { + if (state.restored) { + return; + } + state.restored = true; + + const storage = getSessionStorage(); + if (!storage) { + return; + } + + let raw = null; + try { + raw = storage.getItem(SESSION_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to read session cache:", error); + } + return; + } + + if (!raw || typeof raw !== "string") { + return; + } + + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to parse session cache:", error); + } + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear corrupt session cache:", + cleanupError + ); + } + } + return; + } + + if (!parsed || parsed.version !== SESSION_STORAGE_VERSION) { + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear outdated session cache:", + cleanupError + ); + } + } + return; + } + + const actors = parsed.actors; + if (!actors || typeof actors !== "object") { + return; + } + + const now = Date.now(); + for (const [actor, entry] of Object.entries(actors)) { + const actorKey = normalizeActorKey(actor); + if (!actorKey) { + continue; + } + const queue = getOrCreateQueue(actorKey); + queue.items.clear(); + + const items = Array.isArray(entry?.items) ? entry.items : []; + for (const candidate of items) { + const pointer = normalizePointerInput(candidate); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + queue.items.set(key, { + pointer, + addedAt: now, + updatedAt: now, + }); + } + + if (typeof entry?.pendingSnapshotId === "string" && entry.pendingSnapshotId) { + queue.pendingSnapshotId = entry.pendingSnapshotId; + } else { + queue.pendingSnapshotId = null; + } + if ( + typeof entry?.lastSnapshotReason === "string" && + entry.lastSnapshotReason + ) { + queue.lastSnapshotReason = entry.lastSnapshotReason; + } else { + queue.lastSnapshotReason = null; + } + + if (queue.pendingSnapshotId && isFeatureEnabled()) { + scheduleRepublishForQueue(actorKey); + } + } +} + +function ensureQueue(actorKey) { + if (!actorKey) { + return null; + } + if (!state.restored) { + restoreQueueState(); + } + return getOrCreateQueue(actorKey); +} + +function persistQueueState() { + const storage = getSessionStorage(); + if (!storage) { + return; + } + + const payload = { version: SESSION_STORAGE_VERSION, actors: {} }; + let hasEntries = false; + + for (const [actorKey, queue] of state.queues.entries()) { + const items = collectQueueItems(actorKey); + if (!items.length && !queue.pendingSnapshotId) { + continue; + } + payload.actors[actorKey] = { + items, + pendingSnapshotId: queue.pendingSnapshotId, + lastSnapshotReason: queue.lastSnapshotReason, + }; + hasEntries = true; + } + + if (!hasEntries) { + try { + storage.removeItem(SESSION_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear empty session cache:", + error + ); + } + } + return; + } + + try { + storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to persist session cache:", error); + } + } +} + +function resolveActorKey(actorInput) { + const supplied = + typeof actorInput === "string" && actorInput.trim() + ? normalizeActorKey(actorInput) + : ""; + if (supplied) { + return supplied; + } + const logged = normalizeActorKey(nostrClient?.pubkey); + if (logged) { + return logged; + } + const session = normalizeActorKey(nostrClient?.sessionActor?.pubkey); + if (session) { + return session; + } + return ""; +} + +function collectQueueItems(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue) { + return []; + } + const items = []; + for (const { pointer } of queue.items.values()) { + const normalized = normalizePointerInput(pointer); + if (normalized) { + items.push(normalized); + } + } + items.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); + return keyA.localeCompare(keyB); + }); + return items.slice(0, WATCH_HISTORY_MAX_ITEMS); +} + +function notifyQueueChange(actorKey) { + emit("queue-changed", { + actor: actorKey, + items: collectQueueItems(actorKey), + }); +} + +function clearQueue(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue) { + return; + } + queue.items.clear(); + queue.throttle.clear(); + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + notifyQueueChange(actorKey); +} + +function resolveWatchedAt(...candidates) { + for (const candidate of candidates) { + if (!Number.isFinite(candidate)) { + continue; + } + let value = Math.floor(Number(candidate)); + if (value <= 0) { + continue; + } + if (value > 10_000_000_000) { + value = Math.floor(value / 1000); + } + if (value > 0) { + return value; + } + } + return Math.floor(Date.now() / 1000); +} + +function scheduleRepublishForQueue(actorKey) { + const queue = state.queues.get(actorKey); + if (!queue || !queue.pendingSnapshotId || queue.republishScheduled) { + return; + } + const snapshotId = queue.pendingSnapshotId; + queue.republishScheduled = true; + nostrClient.scheduleWatchHistoryRepublish(snapshotId, async (attempt) => { + const latestItems = collectQueueItems(actorKey); + if (!latestItems.length) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + return { + ok: true, + skipped: true, + actor: actorKey, + snapshotId, + }; + } + const publishResult = await nostrClient.publishWatchHistorySnapshot( + latestItems, + { + actorPubkey: actorKey, + snapshotId, + attempt, + source: `${queue.lastSnapshotReason || "session"}-retry`, + } + ); + if (publishResult?.ok) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + await updateFingerprintCache(actorKey, publishResult.items || latestItems); + persistQueueState(); + notifyQueueChange(actorKey); + } else if (!publishResult?.retryable) { + queue.pendingSnapshotId = publishResult?.snapshotId || queue.pendingSnapshotId; + queue.republishScheduled = false; + persistQueueState(); + } + return publishResult; + }); +} + +async function updateFingerprintCache(actorKey, items) { + const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const fingerprint = await nostrClient.getWatchHistoryFingerprint( + actorKey, + items + ); + const entry = state.fingerprintCache.get(actorKey) || {}; + const nextEntry = { + ...entry, + items: Array.isArray(items) ? items : [], + fingerprint, + expiresAt: Date.now() + ttl, + }; + delete nextEntry.promise; + state.fingerprintCache.set(actorKey, nextEntry); + emit("fingerprint", { actor: actorKey, fingerprint, items: nextEntry.items }); + return fingerprint; +} + +async function publishView(pointerInput, createdAt, metadata = {}) { + const recordOptions = {}; + if (Number.isFinite(createdAt)) { + recordOptions.created_at = createdAt; + } + if (metadata && typeof metadata === "object") { + if (Array.isArray(metadata.additionalTags)) { + recordOptions.additionalTags = metadata.additionalTags; + } + if (Array.isArray(metadata.relays)) { + recordOptions.relays = metadata.relays; + } + if (metadata.content != null) { + recordOptions.content = metadata.content; + } + } + + const viewResult = await nostrClient.recordVideoView(pointerInput, recordOptions); + + if (!isFeatureEnabled()) { + return viewResult; + } + + const pointer = normalizePointerInput(pointerInput); + if (!pointer) { + return viewResult; + } + + const actorCandidate = + (typeof metadata.actor === "string" && metadata.actor.trim()) || + viewResult?.event?.pubkey || + nostrClient?.pubkey || + nostrClient?.sessionActor?.pubkey || + ""; + const actorKey = normalizeActorKey(actorCandidate); + if (!actorKey) { + return viewResult; + } + + const queue = ensureQueue(actorKey); + if (!queue) { + return viewResult; + } + + const normalizedPointer = normalizePointerInput(pointer); + if (!normalizedPointer) { + return viewResult; + } + + normalizedPointer.watchedAt = resolveWatchedAt( + createdAt, + viewResult?.event?.created_at + ); + + const normalizedLogged = normalizeActorKey(nostrClient?.pubkey); + const normalizedEventActor = normalizeActorKey( + viewResult?.event?.pubkey || actorCandidate + ); + if (normalizedEventActor && normalizedEventActor !== normalizedLogged) { + normalizedPointer.session = true; + } + + const key = pointerKey(normalizedPointer); + if (!key) { + return viewResult; + } + + const now = Date.now(); + const existing = queue.items.get(key); + const lastSeen = queue.throttle.get(key) || 0; + const throttled = !existing && now - lastSeen < POINTER_THROTTLE_MS; + + if (existing) { + const currentWatched = Number.isFinite(existing.pointer?.watchedAt) + ? existing.pointer.watchedAt + : 0; + if ( + Number.isFinite(normalizedPointer.watchedAt) && + normalizedPointer.watchedAt > currentWatched + ) { + existing.pointer.watchedAt = normalizedPointer.watchedAt; + } + if (normalizedPointer.session === true) { + existing.pointer.session = true; + } + existing.updatedAt = now; + queue.items.set(key, existing); + queue.throttle.set(key, now); + persistQueueState(); + notifyQueueChange(actorKey); + return viewResult; + } + + if (throttled) { + queue.throttle.set(key, now); + if (isDevMode) { + console.info( + `[watchHistoryService] Throttled pointer ${key} for actor ${actorKey}.` + ); + } + return viewResult; + } + + queue.items.set(key, { + pointer: normalizedPointer, + addedAt: now, + updatedAt: now, + }); + queue.throttle.set(key, now); + persistQueueState(); + notifyQueueChange(actorKey); + + return viewResult; +} + +async function snapshot(items, options = {}) { + if (!isFeatureEnabled()) { + return { ok: true, skipped: true, reason: "feature-disabled" }; + } + const reason = typeof options.reason === "string" ? options.reason : "manual"; + const actorKey = resolveActorKey(options.actor); + if (!actorKey) { + return { ok: false, error: "missing-actor" }; + } + + if (state.inflightSnapshots.has(actorKey)) { + return state.inflightSnapshots.get(actorKey); + } + + let payloadItems = []; + if (Array.isArray(items) && items.length) { + payloadItems = items + .map((item) => normalizePointerInput(item)) + .filter(Boolean); + } else { + payloadItems = collectQueueItems(actorKey); + } + + if (!payloadItems.length) { + const result = { ok: true, empty: true, actor: actorKey }; + emit("snapshot-empty", result); + return result; + } + + const queue = ensureQueue(actorKey); + + const run = (async () => { + emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); + const publishResult = await nostrClient.publishWatchHistorySnapshot( + payloadItems, + { + actorPubkey: actorKey, + source: reason, + } + ); + + if (!publishResult?.ok) { + if (publishResult?.retryable && publishResult?.snapshotId) { + if (queue) { + queue.pendingSnapshotId = publishResult.snapshotId; + queue.lastSnapshotReason = reason; + queue.republishScheduled = false; + persistQueueState(); + scheduleRepublishForQueue(actorKey); + } + } + const error = new Error("watch-history-snapshot-failed"); + error.result = publishResult; + emit("snapshot-error", { actor: actorKey, reason, error }); + throw error; + } + + if (!items) { + clearQueue(actorKey); + } else if (queue) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + persistQueueState(); + } + + await updateFingerprintCache(actorKey, publishResult.items || payloadItems); + emit("snapshot-complete", { actor: actorKey, reason, result: publishResult }); + return publishResult; + })() + .catch((error) => { + if (isDevMode) { + console.warn("[watchHistoryService] Snapshot failed:", error); + } + throw error; + }) + .finally(() => { + state.inflightSnapshots.delete(actorKey); + }); + + state.inflightSnapshots.set(actorKey, run); + return run; +} + +async function loadLatest(actorInput) { + if (!isFeatureEnabled()) { + return []; + } + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return []; + } + + const now = Date.now(); + const cacheEntry = state.fingerprintCache.get(actorKey); + if ( + cacheEntry?.items && + cacheEntry.expiresAt && + cacheEntry.expiresAt > now && + Array.isArray(cacheEntry.items) + ) { + return cacheEntry.items; + } + + if (cacheEntry?.promise) { + return cacheEntry.promise; + } + + const promise = nostrClient + .resolveWatchHistory(actorKey, { forceRefresh: true }) + .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) + .then(() => { + const latest = state.fingerprintCache.get(actorKey); + return latest?.items || []; + }) + .catch((error) => { + if (isDevMode) { + console.warn("[watchHistoryService] Failed to load watch history:", error); + } + throw error; + }) + .finally(() => { + const entry = state.fingerprintCache.get(actorKey); + if (entry) { + delete entry.promise; + } + }); + + state.fingerprintCache.set(actorKey, { + ...(cacheEntry || {}), + promise, + }); + + return promise; +} + +async function getFingerprint(actorInput) { + if (!isFeatureEnabled()) { + return ""; + } + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return ""; + } + const now = Date.now(); + const cacheEntry = state.fingerprintCache.get(actorKey); + if ( + cacheEntry?.fingerprint && + cacheEntry.expiresAt && + cacheEntry.expiresAt > now + ) { + return cacheEntry.fingerprint; + } + const fingerprint = await nostrClient.getWatchHistoryFingerprint(actorKey); + const ttl = Math.max(0, Number(WATCH_HISTORY_CACHE_TTL_MS) || 0); + const nextEntry = { + ...(cacheEntry || {}), + fingerprint, + expiresAt: now + ttl, + }; + state.fingerprintCache.set(actorKey, nextEntry); + emit("fingerprint", { actor: actorKey, fingerprint }); + return fingerprint; +} + +function resetProgress(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (actorKey) { + state.queues.delete(actorKey); + state.fingerprintCache.delete(actorKey); + state.inflightSnapshots.delete(actorKey); + persistQueueState(); + notifyQueueChange(actorKey); + return; + } + state.queues.clear(); + state.fingerprintCache.clear(); + state.inflightSnapshots.clear(); + persistQueueState(); + emit("queue-changed", { actor: null, items: [] }); +} + +function getQueuedPointers(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return []; + } + if (!state.restored) { + restoreQueueState(); + } + return collectQueueItems(actorKey); +} + +function getAllQueues() { + if (!state.restored) { + restoreQueueState(); + } + const summary = {}; + for (const actorKey of state.queues.keys()) { + const items = collectQueueItems(actorKey); + if (items.length) { + summary[actorKey] = items; + } + } + return summary; +} + +const watchHistoryService = { + isEnabled: isFeatureEnabled, + publishView, + snapshot, + loadLatest, + getFingerprint, + resetProgress, + getQueuedPointers, + getAllQueues, + subscribe, +}; + +if (typeof window !== "undefined") { + window.bitvid = window.bitvid || {}; + window.bitvid.watchHistory = watchHistoryService; +} + +export { watchHistoryService }; +export default watchHistoryService; From 9945c024fcc934d782c6cdbcca108d395b7537d3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:16:48 -0400 Subject: [PATCH 69/82] Refactor watch history renderer --- js/historyView.js | 1379 ++++++++++++++++++++++++++++++++++++++++++-- views/history.html | 86 ++- 2 files changed, 1420 insertions(+), 45 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 4d025f50..6fe9d2d2 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1,8 +1,38 @@ // js/historyView.js +import watchHistoryService from "./watchHistoryService.js"; +import { + pointerKey, + normalizePointerInput, + nostrClient, + updateWatchHistoryList, +} from "./nostr.js"; +import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js"; + export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; +const WATCH_HISTORY_METADATA_PREF_KEY = + "bitvid:watch-history:metadata-preference"; +const WATCH_HISTORY_PRIVACY_DISMISSED_KEY = + "bitvid:watch-history:privacy-banner-dismissed"; +const WATCH_HISTORY_BATCH_SIZE = 12; +const FALLBACK_THUMBNAIL = "assets/svg/default-thumbnail.svg"; +const FALLBACK_AVATAR = "assets/svg/default-profile.svg"; +const isDevEnv = + typeof process !== "undefined" && + process?.env?.NODE_ENV !== "production"; + +function escapeSelector(value) { + if (typeof value !== "string") { + return ""; + } + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + return value.replace(/([^a-zA-Z0-9_-])/g, "\\$1"); +} + function resolveElement(selector) { if (!selector || typeof selector !== "string") { return null; @@ -34,22 +64,642 @@ function setTextContent(element, text) { element.textContent = text; } +function getAppInstance() { + if (typeof window === "undefined") { + return null; + } + if (window.app) { + return window.app; + } + if (window.bitvid && window.bitvid.app) { + return window.bitvid.app; + } + return null; +} + +function safeLocaleDate(date) { + try { + return date.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + }); + } catch (error) { + return date.toDateString(); + } +} + +function formatDayLabel(timestampSeconds) { + if (!Number.isFinite(timestampSeconds)) { + return "Unknown day"; + } + const eventDate = new Date(timestampSeconds * 1000); + if (Number.isNaN(eventDate.getTime())) { + return "Unknown day"; + } + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const targetDay = eventDate.toDateString(); + if (targetDay === today.toDateString()) { + return "Today"; + } + if (targetDay === yesterday.toDateString()) { + return "Yesterday"; + } + return safeLocaleDate(eventDate); +} + +function formatRelativeTime(timestampSeconds) { + if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) { + return "Unknown time"; + } + const app = getAppInstance(); + if (app && typeof app.formatTimeAgo === "function") { + return app.formatTimeAgo(timestampSeconds); + } + const nowSeconds = Math.floor(Date.now() / 1000); + const delta = Math.max(0, nowSeconds - Math.floor(timestampSeconds)); + const units = [ + { label: "year", seconds: 31536000 }, + { label: "month", seconds: 2592000 }, + { label: "week", seconds: 604800 }, + { label: "day", seconds: 86400 }, + { label: "hour", seconds: 3600 }, + { label: "minute", seconds: 60 }, + ]; + for (const unit of units) { + const count = Math.floor(delta / unit.seconds); + if (count >= 1) { + return `${count} ${unit.label}${count > 1 ? "s" : ""} ago`; + } + } + return "just now"; +} + +function computeFingerprint(items) { + if (!Array.isArray(items) || !items.length) { + return ""; + } + const pieces = []; + for (const entry of items) { + const pointer = normalizePointerInput(entry); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + const watchedAt = Number.isFinite(entry?.watchedAt) ? entry.watchedAt : 0; + pieces.push(`${key}:${watchedAt}`); + } + return pieces.join("|"); +} + +function normalizeHistoryItems(rawItems) { + if (!Array.isArray(rawItems)) { + return []; + } + const normalized = []; + for (const candidate of rawItems) { + const pointer = normalizePointerInput(candidate); + if (!pointer) { + continue; + } + const key = pointerKey(pointer); + if (!key) { + continue; + } + const watchedAtRaw = Number.isFinite(candidate?.watchedAt) + ? candidate.watchedAt + : Number.isFinite(candidate?.timestamp) + ? candidate.timestamp + : null; + const watchedAt = + watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0; + normalized.push({ + pointer, + pointerKey: key, + watchedAt, + raw: candidate, + }); + } + normalized.sort((a, b) => { + if (a.watchedAt !== b.watchedAt) { + return b.watchedAt - a.watchedAt; + } + return a.pointerKey.localeCompare(b.pointerKey); + }); + return normalized; +} + +async function defaultRemoveHandler({ + actor, + items, + snapshot, + reason = "remove-item", +}) { + const sanitized = Array.isArray(items) + ? items.map((entry) => ({ + ...entry.pointer, + watchedAt: entry.watchedAt, + })) + : []; + if (typeof snapshot === "function") { + await snapshot(sanitized, { actor, reason }); + } + try { + if (typeof updateWatchHistoryList === "function") { + await updateWatchHistoryList(sanitized, { + actorPubkey: actor, + replace: true, + source: reason, + }); + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to update watch history list:", error); + } + throw error; + } +} + +function getStorage() { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage || null; + } catch (error) { + return null; + } +} + +function readPreference(key, fallback) { + const storage = getStorage(); + if (!storage) { + return fallback; + } + try { + const stored = storage.getItem(key); + return stored === null ? fallback : stored; + } catch (error) { + return fallback; + } +} + +function writePreference(key, value) { + const storage = getStorage(); + if (!storage) { + return; + } + try { + if (value === null || typeof value === "undefined") { + storage.removeItem(key); + return; + } + storage.setItem(key, value); + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to persist preference:", error); + } + } +} + +function removeEmptyDayContainers(grid) { + if (!(grid instanceof HTMLElement)) { + return; + } + const daySections = grid.querySelectorAll("[data-history-day]"); + daySections.forEach((section) => { + if (!(section instanceof HTMLElement)) { + return; + } + const list = section.querySelector("[data-history-day-list]"); + if (list instanceof HTMLElement && list.childElementCount === 0) { + section.remove(); + } + }); +} + +function getPointerVideoId(video, pointer) { + if (video && typeof video.id === "string" && video.id) { + return video.id; + } + if (pointer?.type === "e" && typeof pointer.value === "string") { + return pointer.value; + } + return ""; +} + +async function resolveVideoFromPointer(pointer, caches) { + if (!pointer) { + return null; + } + const app = getAppInstance(); + const type = pointer.type === "a" ? "a" : "e"; + const value = typeof pointer.value === "string" ? pointer.value.trim() : ""; + if (!value) { + return null; + } + + const normalizeVideo = (video) => { + if (!video || typeof video !== "object") { + return null; + } + if (video.deleted) { + return null; + } + return video; + }; + + if (type === "e") { + const fromCache = app?.videosMap?.get(value); + if (fromCache) { + const normalized = normalizeVideo(fromCache); + if (normalized) { + return normalized; + } + } + if (nostrClient?.allEvents instanceof Map) { + const event = nostrClient.allEvents.get(value); + if (event) { + const normalized = normalizeVideo(event); + if (normalized) { + return normalized; + } + } + } + if (typeof app?.getOldEventById === "function") { + try { + const fetched = await app.getOldEventById(value); + if (fetched) { + const normalized = normalizeVideo(fetched); + if (normalized) { + return normalized; + } + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to load video via app cache:", error); + } + } + } + if (typeof nostrClient?.getEventById === "function") { + try { + const fetched = await nostrClient.getEventById(value); + const normalized = normalizeVideo(fetched); + if (normalized) { + return normalized; + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to fetch event by id:", error); + } + } + } + return null; + } + + const compareAddress = (video) => { + if (!video || typeof video !== "object") { + return false; + } + if (typeof app?.getVideoAddressPointer !== "function") { + return false; + } + const address = app.getVideoAddressPointer(video); + return typeof address === "string" && address.trim() === value; + }; + + if (app?.videosMap instanceof Map) { + for (const cached of app.videosMap.values()) { + if (compareAddress(cached)) { + const normalized = normalizeVideo(cached); + if (normalized) { + return normalized; + } + } + } + } + + const activeVideos = Array.isArray(caches?.activeVideos) + ? caches.activeVideos + : typeof nostrClient?.getActiveVideos === "function" + ? nostrClient.getActiveVideos() + : []; + for (const candidate of activeVideos) { + if (compareAddress(candidate)) { + const normalized = normalizeVideo(candidate); + if (normalized) { + return normalized; + } + } + } + + if (!caches?.catalogPromise && typeof nostrClient?.fetchVideos === "function") { + caches.catalogPromise = nostrClient.fetchVideos().catch((error) => { + if (isDevEnv) { + console.warn("[historyView] Failed to fetch video catalog:", error); + } + return []; + }); + } + if (caches?.catalogPromise) { + try { + const catalog = await caches.catalogPromise; + if (Array.isArray(catalog)) { + for (const candidate of catalog) { + if (compareAddress(candidate)) { + const normalized = normalizeVideo(candidate); + if (normalized) { + return normalized; + } + } + } + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to search catalog for pointer:", error); + } + } + } + + return null; +} + +function resolveProfileForPubkey(pubkey) { + if (typeof pubkey !== "string" || !pubkey.trim()) { + return null; + } + const app = getAppInstance(); + if (!app) { + return null; + } + try { + const cacheEntry = app.getProfileCacheEntry + ? app.getProfileCacheEntry(pubkey) + : null; + if (cacheEntry && typeof cacheEntry === "object") { + return cacheEntry.profile || null; + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to read profile cache:", error); + } + } + return null; +} + +function buildHistoryCard({ + item, + video, + profile, + metadataPreference, +}) { + const article = document.createElement("article"); + article.className = "watch-history-card"; + article.dataset.pointerKey = item.pointerKey; + if (video?.isPrivate) { + article.classList.add("watch-history-card--private"); + } + + const primary = document.createElement("div"); + primary.className = "watch-history-card__primary"; + + const playbackData = (() => { + if (!video) { + return { url: "", magnet: "" }; + } + const url = typeof video.url === "string" ? video.url.trim() : ""; + const magnetRaw = + typeof video.magnet === "string" + ? video.magnet.trim() + : typeof video.infoHash === "string" + ? video.infoHash.trim() + : ""; + return { url, magnet: magnetRaw }; + })(); + + const pointerVideoId = getPointerVideoId(video, item.pointer); + + const thumbnailLink = document.createElement("a"); + thumbnailLink.className = "watch-history-card__thumbnail"; + thumbnailLink.href = "#"; + thumbnailLink.dataset.historyAction = "play"; + thumbnailLink.dataset.pointerKey = item.pointerKey; + if (pointerVideoId) { + thumbnailLink.dataset.videoId = pointerVideoId; + } + if (playbackData.url) { + thumbnailLink.dataset.playUrl = encodeURIComponent(playbackData.url); + } + if (playbackData.magnet) { + thumbnailLink.dataset.playMagnet = playbackData.magnet; + } + + const thumbnailInner = document.createElement("div"); + thumbnailInner.className = "watch-history-card__thumbnailInner"; + const thumbnailImg = document.createElement("img"); + thumbnailImg.alt = video?.title || "Video thumbnail"; + const thumbnailSrc = + (video && typeof video.thumbnail === "string" && video.thumbnail.trim()) + ? video.thumbnail.trim() + : FALLBACK_THUMBNAIL; + thumbnailImg.src = thumbnailSrc; + thumbnailImg.loading = "lazy"; + thumbnailImg.decoding = "async"; + thumbnailInner.appendChild(thumbnailImg); + thumbnailLink.appendChild(thumbnailInner); + + const details = document.createElement("div"); + details.className = "watch-history-card__details"; + + const titleLink = document.createElement("a"); + titleLink.className = "watch-history-card__title"; + titleLink.href = "#"; + titleLink.dataset.historyAction = "play"; + titleLink.dataset.pointerKey = item.pointerKey; + if (pointerVideoId) { + titleLink.dataset.videoId = pointerVideoId; + } + if (playbackData.url) { + titleLink.dataset.playUrl = encodeURIComponent(playbackData.url); + } + if (playbackData.magnet) { + titleLink.dataset.playMagnet = playbackData.magnet; + } + titleLink.textContent = video?.title || "Untitled video"; + + const created = document.createElement("p"); + created.className = "watch-history-card__created"; + const createdAt = Number.isFinite(video?.created_at) ? video.created_at : null; + created.textContent = createdAt + ? `Published ${formatRelativeTime(createdAt)}` + : "Published date unavailable"; + + details.appendChild(titleLink); + details.appendChild(created); + + primary.appendChild(thumbnailLink); + primary.appendChild(details); + + const meta = document.createElement("div"); + meta.className = "watch-history-card__meta"; + + const watched = document.createElement("p"); + watched.className = "watch-history-card__watched"; + watched.textContent = item.watchedAt + ? `Watched ${formatRelativeTime(item.watchedAt)}` + : "Watched time unavailable"; + meta.appendChild(watched); + + const creator = document.createElement("div"); + creator.className = "watch-history-card__creator"; + + const creatorAvatarButton = document.createElement("button"); + creatorAvatarButton.type = "button"; + creatorAvatarButton.className = "watch-history-card__creatorAvatar"; + creatorAvatarButton.dataset.historyAction = "channel"; + creatorAvatarButton.dataset.pointerKey = item.pointerKey; + if (video?.pubkey) { + creatorAvatarButton.dataset.author = video.pubkey; + } + const avatarImg = document.createElement("img"); + const avatarSrc = + (profile && typeof profile.picture === "string" && profile.picture.trim()) + ? profile.picture.trim() + : FALLBACK_AVATAR; + avatarImg.src = avatarSrc; + avatarImg.alt = profile?.name || profile?.display_name || "Creator avatar"; + creatorAvatarButton.appendChild(avatarImg); + + const creatorNameButton = document.createElement("button"); + creatorNameButton.type = "button"; + creatorNameButton.className = "watch-history-card__creatorName"; + creatorNameButton.dataset.historyAction = "channel"; + creatorNameButton.dataset.pointerKey = item.pointerKey; + if (video?.pubkey) { + creatorNameButton.dataset.author = video.pubkey; + } + const app = getAppInstance(); + let creatorLabel = + profile?.display_name || profile?.name || profile?.username || "Unknown"; + if ((!creatorLabel || creatorLabel === "Unknown") && video?.pubkey) { + const encoded = app?.safeEncodeNpub?.(video.pubkey) || ""; + creatorLabel = encoded || video.pubkey.slice(0, 8).concat("…"); + } + creatorNameButton.textContent = creatorLabel; + + creator.appendChild(creatorAvatarButton); + creator.appendChild(creatorNameButton); + meta.appendChild(creator); + + const actions = document.createElement("div"); + actions.className = "watch-history-card__actions"; + + const playButton = document.createElement("button"); + playButton.type = "button"; + playButton.className = + "watch-history-card__action watch-history-card__action--primary"; + playButton.dataset.historyAction = "play"; + playButton.dataset.pointerKey = item.pointerKey; + if (pointerVideoId) { + playButton.dataset.videoId = pointerVideoId; + } + if (playbackData.url) { + playButton.dataset.playUrl = encodeURIComponent(playbackData.url); + } + if (playbackData.magnet) { + playButton.dataset.playMagnet = playbackData.magnet; + } + playButton.textContent = "Play"; + + const channelButton = document.createElement("button"); + channelButton.type = "button"; + channelButton.className = "watch-history-card__action"; + channelButton.dataset.historyAction = "channel"; + channelButton.dataset.pointerKey = item.pointerKey; + if (video?.pubkey) { + channelButton.dataset.author = video.pubkey; + } + channelButton.textContent = "Open channel"; + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = + "watch-history-card__action watch-history-card__action--danger"; + removeButton.dataset.historyAction = "remove"; + removeButton.dataset.pointerKey = item.pointerKey; + removeButton.textContent = "Remove from history"; + removeButton.setAttribute("aria-label", "Remove this entry from history"); + + actions.appendChild(playButton); + actions.appendChild(channelButton); + actions.appendChild(removeButton); + + meta.appendChild(actions); + + if (metadataPreference === "relay-opt-in") { + const hint = document.createElement("p"); + hint.className = "text-xs text-blue-200"; + hint.textContent = "Metadata is shared with relays for this entry."; + meta.appendChild(hint); + } + + article.appendChild(primary); + article.appendChild(meta); + + return article; +} + export function createWatchHistoryRenderer(config = {}) { const { + fetchHistory = watchHistoryService.loadLatest.bind(watchHistoryService), + snapshot = watchHistoryService.snapshot.bind(watchHistoryService), + remove, + getActor, viewSelector = "#watchHistoryView", gridSelector = "#watchHistoryGrid", loadingSelector = "#watchHistoryLoading", statusSelector = "#watchHistoryStatus", emptySelector = "#watchHistoryEmpty", + sentinelSelector = "#watchHistorySentinel", + loadMoreSelector = "#watchHistoryLoadMore", + clearButtonSelector = "[data-history-action=\"clear-cache\"]", + republishButtonSelector = "[data-history-action=\"republish\"]", + privacyBannerSelector = "#watchHistoryPrivacyBanner", + privacyMessageSelector = "#watchHistoryPrivacyMessage", + privacyToggleSelector = "#watchHistoryPrivacyToggle", + privacyDismissSelector = "#watchHistoryPrivacyDismiss", + infoSelector = "#watchHistoryInfo", + errorBannerSelector = "#watchHistoryError", + scrollContainerSelector = null, emptyCopy = WATCH_HISTORY_EMPTY_COPY, + batchSize = WATCH_HISTORY_BATCH_SIZE, } = config; const state = { initialized: false, - resolvedVideos: [], + actor: null, + items: [], + fingerprint: "", + cursor: 0, hasMore: false, isLoading: false, - actor: null, + isRendering: false, + lastError: null, + observer: null, + observerAttached: false, + metadataPreference: "encrypted-only", + privacyDismissed: false, + metadataCache: new Map(), + catalogCaches: { + activeVideos: null, + catalogPromise: null, + }, }; let elements = { @@ -58,26 +708,74 @@ export function createWatchHistoryRenderer(config = {}) { loading: null, status: null, empty: null, + sentinel: null, + loadMore: null, + clearButton: null, + republishButton: null, + privacyBanner: null, + privacyMessage: null, + privacyToggle: null, + privacyDismiss: null, + info: null, + errorBanner: null, + scrollContainer: null, }; - const refreshElements = () => { + let boundGridClickHandler = null; + let boundLoadMoreHandler = null; + let boundClearHandler = null; + let boundRepublishHandler = null; + let boundPrivacyToggleHandler = null; + let boundPrivacyDismissHandler = null; + + function refreshElements() { elements = { view: resolveElement(viewSelector), grid: resolveElement(gridSelector), loading: resolveElement(loadingSelector), status: resolveElement(statusSelector), empty: resolveElement(emptySelector), + sentinel: resolveElement(sentinelSelector), + loadMore: resolveElement(loadMoreSelector), + clearButton: resolveElement(clearButtonSelector), + republishButton: resolveElement(republishButtonSelector), + privacyBanner: resolveElement(privacyBannerSelector), + privacyMessage: resolveElement(privacyMessageSelector), + privacyToggle: resolveElement(privacyToggleSelector), + privacyDismiss: resolveElement(privacyDismissSelector), + info: resolveElement(infoSelector), + errorBanner: resolveElement(errorBannerSelector), + scrollContainer: resolveElement(scrollContainerSelector), }; - }; + } - const hideLoading = () => { - setHidden(elements.loading, true); - if (elements.status) { - setHidden(elements.status, true); + function setLoadingState(loading) { + state.isLoading = loading; + if (elements.loading) { + setHidden(elements.loading, !loading); } - }; + if (elements.status) { + setHidden(elements.status, !loading); + } + } - const showEmpty = () => { + function showErrorBanner(message) { + if (!(elements.errorBanner instanceof HTMLElement)) { + return; + } + elements.errorBanner.textContent = message; + setHidden(elements.errorBanner, false); + } + + function hideErrorBanner() { + if (!(elements.errorBanner instanceof HTMLElement)) { + return; + } + elements.errorBanner.textContent = ""; + setHidden(elements.errorBanner, true); + } + + function showEmptyState() { if (elements.grid) { elements.grid.innerHTML = ""; setHidden(elements.grid, true); @@ -86,55 +784,652 @@ export function createWatchHistoryRenderer(config = {}) { setTextContent(elements.empty, emptyCopy); setHidden(elements.empty, false); } - }; + setHidden(elements.loadMore, true); + if (elements.sentinel) { + setHidden(elements.sentinel, true); + } + } - const resetUi = () => { - refreshElements(); - hideLoading(); - showEmpty(); - }; + function ensureObserver() { + if (!elements.sentinel || !WATCH_HISTORY_BATCH_RESOLVE) { + return; + } + if (state.observer) { + return; + } + if (typeof IntersectionObserver !== "function") { + return; + } + state.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + void renderer.loadMore(); + } + } + }, + { + root: elements.scrollContainer || null, + rootMargin: "0px 0px 320px 0px", + threshold: 0.1, + } + ); + } - return { - async init() { - resetUi(); - state.initialized = true; - state.resolvedVideos = []; + function attachObserver() { + if (!state.observer || !elements.sentinel) { + return; + } + if (!state.hasMore) { + return; + } + if (!state.observerAttached) { + state.observer.observe(elements.sentinel); + state.observerAttached = true; + setHidden(elements.sentinel, false); + } + } + + function detachObserver() { + if (state.observer && state.observerAttached && elements.sentinel) { + state.observer.unobserve(elements.sentinel); + state.observerAttached = false; + setHidden(elements.sentinel, true); + } + } + + function updateLoadMoreVisibility() { + if (WATCH_HISTORY_BATCH_RESOLVE) { + if (state.hasMore) { + attachObserver(); + } else { + detachObserver(); + } + if (elements.loadMore) { + setHidden(elements.loadMore, true); + } + return; + } + detachObserver(); + if (elements.loadMore) { + setHidden(elements.loadMore, !state.hasMore); + elements.loadMore.disabled = !state.hasMore; + } + } + + function updatePrivacyBanner() { + if (!(elements.privacyBanner instanceof HTMLElement)) { + return; + } + const preference = state.metadataPreference; + const dismissed = state.privacyDismissed; + if (dismissed && preference !== "relay-opt-in") { + setHidden(elements.privacyBanner, true); + return; + } + let message = + "Your history stays encrypted. Share metadata with relays to sync thumbnails across devices?"; + let toggleLabel = "Share metadata"; + if (preference === "relay-opt-in") { + message = + "You are sharing metadata with relays so your thumbnails and titles stay in sync."; + toggleLabel = "Keep encrypted only"; + } + if (elements.privacyMessage) { + elements.privacyMessage.textContent = message; + } + if (elements.privacyToggle) { + elements.privacyToggle.textContent = toggleLabel; + } + setHidden(elements.privacyBanner, false); + } + + function updateInfoCallout() { + if (!(elements.info instanceof HTMLElement)) { + return; + } + const message = + "Thumbnails and titles are hydrated locally from your device caches. Nothing is published unless you opt in."; + elements.info.textContent = message; + } + + async function resolveActorKey() { + if (typeof getActor === "function") { + try { + const result = await getActor(); + if (typeof result === "string" && result.trim()) { + return result.trim(); + } + } catch (error) { + console.warn("[historyView] Failed to resolve actor via getActor:", error); + } + } + if (watchHistoryService?.publishView) { + const pubkey = nostrClient?.pubkey || window?.app?.pubkey || ""; + if (typeof pubkey === "string" && pubkey.trim()) { + return pubkey.trim(); + } + } + return undefined; + } + + async function hydrateBatch(batch) { + const hydrated = []; + const caches = state.catalogCaches; + if (!caches.activeVideos && typeof nostrClient?.getActiveVideos === "function") { + caches.activeVideos = nostrClient.getActiveVideos(); + } + for (const item of batch) { + if (!item) { + continue; + } + if (state.metadataCache.has(item.pointerKey)) { + hydrated.push({ ...item, metadata: state.metadataCache.get(item.pointerKey) }); + continue; + } + let video = null; + try { + video = await resolveVideoFromPointer(item.pointer, caches); + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to resolve video for pointer:", error); + } + } + const profile = video?.pubkey ? resolveProfileForPubkey(video.pubkey) : null; + const metadata = { video, profile }; + state.metadataCache.set(item.pointerKey, metadata); + hydrated.push({ ...item, metadata }); + } + return hydrated; + } + + async function renderNextBatch() { + if (!Array.isArray(state.items) || !state.items.length) { + showEmptyState(); + return; + } + if (!(elements.grid instanceof HTMLElement)) { + return; + } + if (state.cursor >= state.items.length) { state.hasMore = false; - state.isLoading = false; + updateLoadMoreVisibility(); + return; + } + if (state.isRendering) { + return; + } + state.isRendering = true; + const start = state.cursor; + const end = Math.min(state.items.length, start + Math.max(1, batchSize)); + const slice = state.items.slice(start, end); + let hydrated; + try { + hydrated = await hydrateBatch(slice); + } catch (error) { + state.isRendering = false; + if (isDevEnv) { + console.warn("[historyView] Failed to hydrate batch:", error); + } + return; + } + + const fragment = document.createDocumentFragment(); + const dayContainers = new Map(); + + const ensureDayContainer = (dayKey, label) => { + if (dayContainers.has(dayKey)) { + return dayContainers.get(dayKey); + } + let section = elements.grid.querySelector( + `[data-history-day="${escapeSelector(dayKey)}"]` + ); + if (!(section instanceof HTMLElement)) { + section = document.createElement("section"); + section.dataset.historyDay = dayKey; + section.className = "space-y-4"; + const header = document.createElement("h3"); + header.className = "text-sm font-semibold uppercase tracking-wide text-gray-300"; + header.textContent = label; + const list = document.createElement("div"); + list.dataset.historyDayList = "true"; + list.className = "space-y-4"; + section.appendChild(header); + section.appendChild(list); + fragment.appendChild(section); + } + const list = section.querySelector("[data-history-day-list]"); + const record = { section, list }; + dayContainers.set(dayKey, record); + return record; + }; + + hydrated.forEach((entry) => { + if (!entry) { + return; + } + const dayLabel = formatDayLabel(entry.watchedAt); + const dayKey = `${dayLabel}`; + const container = ensureDayContainer(dayKey, dayLabel); + if (!(container?.list instanceof HTMLElement)) { + return; + } + const card = buildHistoryCard({ + item: entry, + video: entry.metadata?.video || null, + profile: entry.metadata?.profile || null, + metadataPreference: state.metadataPreference, + }); + container.list.appendChild(card); + }); + + elements.grid.appendChild(fragment); + setHidden(elements.grid, false); + setHidden(elements.empty, true); + + state.cursor = end; + state.hasMore = state.cursor < state.items.length; + updateLoadMoreVisibility(); + state.isRendering = false; + } + + function bindGridEvents() { + if (!(elements.grid instanceof HTMLElement)) { + return; + } + if (boundGridClickHandler) { + elements.grid.removeEventListener("click", boundGridClickHandler); + } + boundGridClickHandler = async (event) => { + const trigger = event.target instanceof HTMLElement + ? event.target.closest("[data-history-action]") + : null; + if (!(trigger instanceof HTMLElement)) { + return; + } + const action = trigger.dataset.historyAction || ""; + const pointerKeyAttr = trigger.dataset.pointerKey || (trigger.closest("[data-pointer-key]")?.dataset.pointerKey ?? ""); + if (!action) { + return; + } + switch (action) { + case "play": { + event.preventDefault(); + const videoId = trigger.dataset.videoId || pointerKeyAttr; + const app = getAppInstance(); + if (videoId && app?.playVideoByEventId) { + app.playVideoByEventId(videoId); + return; + } + const urlAttr = trigger.dataset.playUrl || ""; + const magnetAttr = trigger.dataset.playMagnet || ""; + if (app?.playVideoWithFallback) { + app.playVideoWithFallback({ + url: urlAttr ? decodeURIComponent(urlAttr) : "", + magnet: magnetAttr, + }); + } + break; + } + case "channel": { + event.preventDefault(); + const author = trigger.dataset.author || ""; + const app = getAppInstance(); + if (author && app?.goToProfile) { + app.goToProfile(author); + } else if (app?.showError) { + app.showError("No creator info available."); + } + break; + } + case "remove": { + event.preventDefault(); + if (pointerKeyAttr) { + await renderer.handleRemove(pointerKeyAttr); + } + break; + } + default: + break; + } + }; + elements.grid.addEventListener("click", boundGridClickHandler); + } + + function bindLoadMore() { + if (!(elements.loadMore instanceof HTMLElement)) { + return; + } + if (boundLoadMoreHandler) { + elements.loadMore.removeEventListener("click", boundLoadMoreHandler); + } + boundLoadMoreHandler = (event) => { + event.preventDefault(); + void renderer.loadMore(); + }; + elements.loadMore.addEventListener("click", boundLoadMoreHandler); + } + + function bindActions() { + if (elements.clearButton) { + if (boundClearHandler) { + elements.clearButton.removeEventListener("click", boundClearHandler); + } + boundClearHandler = async (event) => { + event.preventDefault(); + const actor = await resolveActorKey(); + try { + await watchHistoryService.resetProgress(actor); + state.items = []; + state.cursor = 0; + state.hasMore = false; + state.metadataCache.clear(); + showEmptyState(); + const app = getAppInstance(); + app?.showSuccess?.("Local watch history cache cleared."); + } catch (error) { + const message = + error && typeof error.message === "string" + ? error.message + : "Failed to clear local watch history cache."; + const app = getAppInstance(); + app?.showError?.(message); + } + }; + elements.clearButton.addEventListener("click", boundClearHandler); + } + + if (elements.republishButton) { + if (boundRepublishHandler) { + elements.republishButton.removeEventListener("click", boundRepublishHandler); + } + boundRepublishHandler = async (event) => { + event.preventDefault(); + const actor = await resolveActorKey(); + try { + const payload = state.items.map((entry) => ({ + ...entry.pointer, + watchedAt: entry.watchedAt, + })); + await snapshot(payload, { actor, reason: "manual-republish" }); + const app = getAppInstance(); + app?.showSuccess?.("Watch history snapshot queued for publish."); + } catch (error) { + const app = getAppInstance(); + app?.showError?.("Failed to publish watch history. Try again later."); + if (isDevEnv) { + console.warn("[historyView] Republish failed:", error); + } + } + }; + elements.republishButton.addEventListener("click", boundRepublishHandler); + } + } + + function bindPrivacyControls() { + if (elements.privacyToggle) { + if (boundPrivacyToggleHandler) { + elements.privacyToggle.removeEventListener( + "click", + boundPrivacyToggleHandler + ); + } + boundPrivacyToggleHandler = (event) => { + event.preventDefault(); + if (state.metadataPreference === "relay-opt-in") { + state.metadataPreference = "encrypted-only"; + } else { + state.metadataPreference = "relay-opt-in"; + state.privacyDismissed = false; + } + writePreference( + WATCH_HISTORY_METADATA_PREF_KEY, + state.metadataPreference + ); + updatePrivacyBanner(); + renderer.render(); + }; + elements.privacyToggle.addEventListener("click", boundPrivacyToggleHandler); + } + + if (elements.privacyDismiss) { + if (boundPrivacyDismissHandler) { + elements.privacyDismiss.removeEventListener( + "click", + boundPrivacyDismissHandler + ); + } + boundPrivacyDismissHandler = (event) => { + event.preventDefault(); + state.privacyDismissed = true; + writePreference(WATCH_HISTORY_PRIVACY_DISMISSED_KEY, "true"); + updatePrivacyBanner(); + }; + elements.privacyDismiss.addEventListener("click", boundPrivacyDismissHandler); + } + } + + async function loadHistory({ force = false } = {}) { + if (state.isLoading) { + return false; + } + setLoadingState(true); + const actor = await resolveActorKey(); + state.actor = actor; + let items = []; + try { + items = await fetchHistory(actor); + state.lastError = null; + hideErrorBanner(); + } catch (error) { + state.lastError = error; + const message = + error && typeof error.message === "string" + ? error.message + : "Failed to load watch history from relays."; + showErrorBanner(message); + items = []; + } + setLoadingState(false); + const normalized = normalizeHistoryItems(items); + const fingerprint = computeFingerprint(normalized); + if (!force && fingerprint && fingerprint === state.fingerprint) { + return false; + } + state.items = normalized; + state.fingerprint = fingerprint; + state.cursor = 0; + state.hasMore = state.items.length > 0; + state.metadataCache.clear(); + state.catalogCaches.catalogPromise = null; + state.catalogCaches.activeVideos = null; + return true; + } + + async function renderInitial() { + if (!(elements.grid instanceof HTMLElement)) { + return; + } + state.cursor = 0; + state.hasMore = state.items.length > 0; + elements.grid.innerHTML = ""; + removeEmptyDayContainers(elements.grid); + if (!state.items.length) { + showEmptyState(); + return; + } + setHidden(elements.empty, true); + setHidden(elements.grid, false); + await renderNextBatch(); + } + + const renderer = { + async init() { + if (typeof document === "undefined") { + return; + } + refreshElements(); + ensureObserver(); + bindGridEvents(); + bindLoadMore(); + bindActions(); + bindPrivacyControls(); + updateInfoCallout(); + const storedPreference = readPreference( + WATCH_HISTORY_METADATA_PREF_KEY, + "encrypted-only" + ); + state.metadataPreference = + storedPreference === "relay-opt-in" ? "relay-opt-in" : "encrypted-only"; + state.privacyDismissed = readPreference( + WATCH_HISTORY_PRIVACY_DISMISSED_KEY, + "false" + ) === "true"; + updatePrivacyBanner(); + state.initialized = true; + await this.refresh(); }, async ensureInitialLoad() { if (!state.initialized) { await this.init(); return; } - resetUi(); - }, - async loadMore() { - return []; - }, - resume() {}, - pause() {}, - destroy() { - state.initialized = false; - state.resolvedVideos = []; - state.hasMore = false; - state.isLoading = false; - hideLoading(); - showEmpty(); + refreshElements(); + await this.refresh(); }, async refresh() { - await this.ensureInitialLoad(); + const changed = await loadHistory({ force: true }); + if (!changed && state.items.length) { + updateLoadMoreVisibility(); + return; + } + await renderInitial(); }, - render() { - resetUi(); + async loadMore() { + if (!state.items.length) { + return []; + } + await renderNextBatch(); + return state.items.slice(0, state.cursor); + }, + resume() { + updateLoadMoreVisibility(); + }, + pause() { + detachObserver(); + }, + destroy() { + detachObserver(); + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + if (elements.grid && boundGridClickHandler) { + elements.grid.removeEventListener("click", boundGridClickHandler); + boundGridClickHandler = null; + } + if (elements.loadMore && boundLoadMoreHandler) { + elements.loadMore.removeEventListener("click", boundLoadMoreHandler); + boundLoadMoreHandler = null; + } + if (elements.clearButton && boundClearHandler) { + elements.clearButton.removeEventListener("click", boundClearHandler); + boundClearHandler = null; + } + if (elements.republishButton && boundRepublishHandler) { + elements.republishButton.removeEventListener("click", boundRepublishHandler); + boundRepublishHandler = null; + } + if (elements.privacyToggle && boundPrivacyToggleHandler) { + elements.privacyToggle.removeEventListener( + "click", + boundPrivacyToggleHandler + ); + boundPrivacyToggleHandler = null; + } + if (elements.privacyDismiss && boundPrivacyDismissHandler) { + elements.privacyDismiss.removeEventListener( + "click", + boundPrivacyDismissHandler + ); + boundPrivacyDismissHandler = null; + } + if (elements.grid) { + elements.grid.innerHTML = ""; + } + state.initialized = false; + state.items = []; + state.cursor = 0; + state.hasMore = false; + state.metadataCache.clear(); + }, + async handleRemove(pointerKeyValue) { + if (!pointerKeyValue) { + return; + } + const index = state.items.findIndex( + (entry) => entry.pointerKey === pointerKeyValue + ); + if (index === -1) { + return; + } + const [removed] = state.items.splice(index, 1); + state.fingerprint = computeFingerprint(state.items); + state.cursor = Math.min(state.cursor, state.items.length); + state.hasMore = state.cursor < state.items.length; + const card = elements.grid?.querySelector( + `[data-pointer-key="${escapeSelector(pointerKeyValue)}"]` + ); + const parentDay = card?.closest("[data-history-day]"); + if (card) { + card.remove(); + } + removeEmptyDayContainers(elements.grid); + if (!state.items.length) { + showEmptyState(); + } else { + updateLoadMoreVisibility(); + } + const remainingItems = state.items.slice(); + const actor = await resolveActorKey(); + try { + const handler = + typeof remove === "function" + ? remove + : (payload) => + defaultRemoveHandler({ + actor, + items: payload, + snapshot, + }); + await handler(remainingItems); + const app = getAppInstance(); + app?.showSuccess?.("Removed from watch history."); + } catch (error) { + const app = getAppInstance(); + app?.showError?.("Failed to remove from history. Reloading list."); + if (isDevEnv) { + console.warn("[historyView] Removal failed:", error); + } + await this.refresh(); + } finally { + if (parentDay instanceof HTMLElement) { + removeEmptyDayContainers(elements.grid); + } + } + }, + async render() { + await renderInitial(); }, getState() { return { ...state, - resolvedVideos: state.resolvedVideos.slice(), + items: state.items.slice(), }; }, }; + + return renderer; } export const watchHistoryRenderer = createWatchHistoryRenderer(); @@ -144,9 +1439,7 @@ export async function initHistoryView() { } if (typeof window !== "undefined") { - if (!window.bitvid) { - window.bitvid = {}; - } + window.bitvid = window.bitvid || {}; window.bitvid.initHistoryView = initHistoryView; window.bitvid.watchHistoryRenderer = watchHistoryRenderer; } diff --git a/views/history.html b/views/history.html index a6ce1557..0b1fb7c8 100644 --- a/views/history.html +++ b/views/history.html @@ -1,6 +1,77 @@
-

Watch History

+
+
+
+

Watch History

+

+ Review the videos you recently played, grouped by day. +

+
+
+ + +
+
+ + + +
+ Thumbnails and titles are hydrated locally from your device caches. +
+ + +
+
+ -
+ + + + +
From a1a4a3bbe1912a96183dc051b6e05d829c49ba26 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:39:23 -0400 Subject: [PATCH 70/82] Enhance profile watch history controls --- components/profile-modal.html | 80 +++++++ js/app.js | 66 +++++- js/historyView.js | 376 +++++++++++++++++++++++++++++-- js/nostr.js | 19 ++ js/watchHistoryService.js | 404 +++++++++++++++++++++++++++++++--- 5 files changed, 895 insertions(+), 50 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 21d520cf..b7e0081f 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -290,7 +290,81 @@

Your recently watched videos appear here so you can jump back into favorites.

+
+
+
+ + +
+
+
+ + Store titles/thumbnails locally + + + When disabled, BitVid skips saving rich metadata to this device. + +
+ +
+
+
+
+