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** diff --git a/components/profile-modal.html b/components/profile-modal.html index 86b844ba..b7e0081f 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -290,19 +290,106 @@

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. + +
+ +
+
+ +
+
+ +
+
+ - 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/adminListStore.js b/js/adminListStore.js index 3b9dd969..1abb14bc 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) { @@ -401,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") { diff --git a/js/app.js b/js/app.js index 55af3fef..f33c9653 100644 --- a/js/app.js +++ b/js/app.js @@ -3,8 +3,8 @@ import { loadView } from "./viewManager.js"; import { nostrClient, - recordVideoView, - updateWatchHistoryList, + normalizePointerInput, + pointerKey as derivePointerKey, } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; @@ -18,6 +18,8 @@ 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 watchHistoryService from "./watchHistoryService.js"; import { loadR2Settings, saveR2Settings, @@ -42,6 +44,14 @@ 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, + unsubscribeFromVideoViewCount, + formatViewCount, + ingestLocalViewEvent, +} from "./viewCounter.js"; function truncateMiddle(text, maxLength = 72) { if (!text || typeof text !== "string") { @@ -531,6 +541,8 @@ class bitvidApp { this.inFlightDiscussionCounts = new Map(); this.activeIntervals = []; this.urlPlaybackWatchdogCleanup = null; + this.watchHistoryMetadataEnabled = null; + this.watchHistoryPreferenceUnsubscribe = null; // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; @@ -586,6 +598,7 @@ class bitvidApp { this.lastFocusedBeforeProfileModal = null; this.boundProfileModalKeydown = null; this.boundProfileModalFocusIn = null; + this.boundProfileHistoryVisibility = null; this.profileModalFocusables = []; this.profileSwitcherList = null; this.profileAddAccountBtn = null; @@ -716,6 +729,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,8 +1257,20 @@ 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(); + 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); } @@ -1277,6 +1305,8 @@ class bitvidApp { // 5. Setup general event listeners this.setupEventListeners(); + await this.initWatchHistoryMetadataSync(); + // 6) Load the default view ONLY if there's no #view= already if (!window.location.hash || !window.location.hash.startsWith("#view=")) { console.log( @@ -1363,6 +1393,9 @@ class bitvidApp { lastScrollY = currentScrollY; }); + this.videoViewCountEl = + document.getElementById("videoViewCount") || null; + console.log("Video modal initialization successful"); return true; } catch (error) { @@ -1386,6 +1419,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 +2684,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; @@ -3959,8 +4216,30 @@ class bitvidApp { emptySelector: "#profileHistoryEmpty", sentinelSelector: "#profileHistorySentinel", scrollContainerSelector: "#profileHistoryScroll", + errorBannerSelector: "#profileHistoryError", + clearButtonSelector: "#profileHistoryClear", + republishButtonSelector: "#profileHistoryRepublish", + featureBannerSelector: "#profileHistoryFeatureBanner", + toastRegionSelector: "#profileHistoryToastRegion", + sessionWarningSelector: "#profileHistorySessionWarning", + metadataToggleSelector: "#profileHistoryMetadataToggle", + metadataThumbSelector: "#profileHistoryMetadataThumb", + metadataLabelSelector: "#profileHistoryMetadataLabel", + metadataDescriptionSelector: "#profileHistoryMetadataDescription", emptyCopy: "You haven’t watched any videos yet.", - getActor: async () => this.pubkey || window.app?.pubkey || undefined, + remove: (payload) => this.handleWatchHistoryRemoval(payload), + getActor: async () => { + if (this.pubkey) { + return this.pubkey; + } + if ( + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey + ) { + return nostrClient.sessionActor.pubkey; + } + return window.app?.pubkey || undefined; + }, }); } this.adminModeratorsSection = @@ -4045,7 +4324,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 ( @@ -4054,7 +4333,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(); + } }); } @@ -4380,6 +4671,13 @@ class bitvidApp { if (this.boundProfileModalFocusIn) { document.removeEventListener("focusin", this.boundProfileModalFocusIn); } + if (this.boundProfileHistoryVisibility) { + document.removeEventListener( + "visibilitychange", + this.boundProfileHistoryVisibility + ); + this.boundProfileHistoryVisibility = null; + } this.closeAllMoreMenus(); @@ -4403,21 +4701,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 = ""; @@ -4430,7 +4747,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"; @@ -4440,11 +4757,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); @@ -4456,9 +4779,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"); @@ -4467,7 +4791,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); @@ -4480,6 +4804,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; @@ -4606,9 +5068,40 @@ class bitvidApp { return; } + let primaryActor = + typeof this.pubkey === "string" && this.pubkey ? this.pubkey : undefined; + if ( + !primaryActor && + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey + ) { + primaryActor = nostrClient.sessionActor.pubkey; + } + try { - await this.profileHistoryRenderer.ensureInitialLoad(); - this.profileHistoryRenderer.resume(); + await this.profileHistoryRenderer.ensureInitialLoad({ actor: primaryActor }); + await this.profileHistoryRenderer.refresh({ actor: primaryActor, force: true }); + if (!this.boundProfileHistoryVisibility) { + this.boundProfileHistoryVisibility = () => { + if (!this.profileHistoryRenderer) { + return; + } + if (document.visibilityState === "visible") { + this.profileHistoryRenderer.resume(); + } else { + this.profileHistoryRenderer.pause(); + } + }; + document.addEventListener( + "visibilitychange", + this.boundProfileHistoryVisibility + ); + } + if (document.visibilityState === "hidden") { + this.profileHistoryRenderer.pause(); + } else { + this.profileHistoryRenderer.resume(); + } } catch (error) { console.error( "[profileModal] Failed to populate watch history pane:", @@ -5545,11 +6038,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..."); @@ -5571,6 +6084,7 @@ class bitvidApp { } } }); + } attachVideoListHandler() { @@ -6158,6 +6672,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", ""); @@ -6214,14 +6736,50 @@ 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() : ""); + + const identityChanged = previousActivePubkey !== nextActivePubkey; + if (identityChanged) { + this.resetViewLoggingState(); + } + + const trimmedPubkey = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; + if (normalizedPubkey) { this.pubkey = normalizedPubkey; + if (nostrClient && typeof nostrClient === "object") { + nostrClient.pubkey = normalizedPubkey; + } } else { this.pubkey = pubkey; + if (nostrClient && typeof nostrClient === "object") { + nostrClient.pubkey = trimmedPubkey ? trimmedPubkey.toLowerCase() : ""; + } } 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); @@ -6283,14 +6841,36 @@ 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(); this.populateBlockedList(); + relayManager.reset(); + this.populateProfileRelays(); + // Show the login button again if (this.loginButton) { this.loginButton.classList.remove("hidden"); @@ -6366,13 +6946,29 @@ 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(); 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") { @@ -6469,8 +7065,8 @@ class bitvidApp { return; } - if (state.timerId) { - clearTimeout(state.timerId); + if (state.viewTimerId) { + clearTimeout(state.viewTimerId); } if (Array.isArray(state.handlers) && state.videoEl) { @@ -6489,6 +7085,460 @@ 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() { + this.cancelPendingViewLogging(); + if (this.loggedViewPointerKeys.size > 0) { + this.loggedViewPointerKeys.clear(); + } + } + + refreshWatchHistoryMetadataSettings() { + if (!watchHistoryService?.isEnabled?.()) { + this.watchHistoryMetadataEnabled = false; + return this.watchHistoryMetadataEnabled; + } + + const previous = this.watchHistoryMetadataEnabled; + let enabled = true; + + try { + if (typeof watchHistoryService.getSettings === "function") { + const settings = watchHistoryService.getSettings(); + enabled = settings?.metadata?.storeLocally !== false; + } else if (typeof watchHistoryService.shouldStoreMetadata === "function") { + enabled = watchHistoryService.shouldStoreMetadata() !== false; + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to read metadata settings:", + error, + ); + } + enabled = true; + } + + this.watchHistoryMetadataEnabled = enabled; + + if (enabled === false && previous !== false) { + try { + watchHistoryService?.clearLocalMetadata?.(); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to purge metadata cache while preference disabled:", + error, + ); + } + } + } + + return this.watchHistoryMetadataEnabled; + } + + async initWatchHistoryMetadataSync() { + if (!watchHistoryService?.isEnabled?.()) { + this.watchHistoryMetadataEnabled = false; + return; + } + + this.refreshWatchHistoryMetadataSettings(); + + if ( + typeof watchHistoryService.subscribe === "function" && + !this.watchHistoryPreferenceUnsubscribe + ) { + try { + const unsubscribe = watchHistoryService.subscribe( + "metadata-preference", + (payload) => { + const previous = this.watchHistoryMetadataEnabled; + const enabled = payload?.enabled !== false; + this.watchHistoryMetadataEnabled = enabled; + if (previous === true && enabled === false) { + try { + watchHistoryService?.clearLocalMetadata?.(); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to clear cached metadata after toggle off:", + error, + ); + } + } + } + }, + ); + if (typeof unsubscribe === "function") { + this.watchHistoryPreferenceUnsubscribe = unsubscribe; + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to subscribe to metadata preference changes:", + error, + ); + } + } + } + } + + persistWatchHistoryMetadataForVideo(video, pointerInfo) { + if ( + !this.watchHistoryMetadataEnabled || + !pointerInfo || + !pointerInfo.key || + typeof watchHistoryService?.setLocalMetadata !== "function" + ) { + return; + } + + if (!video || typeof video !== "object") { + return; + } + + const metadata = { + video: { + id: typeof video.id === "string" ? video.id : "", + title: typeof video.title === "string" ? video.title : "", + thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + }, + }; + + try { + watchHistoryService.setLocalMetadata(pointerInfo.key, metadata); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to persist local metadata for pointer:", + pointerInfo.key, + error, + ); + } + } + } + + dropWatchHistoryMetadata(pointerKey) { + if (!pointerKey || typeof pointerKey !== "string") { + return; + } + if (typeof watchHistoryService?.removeLocalMetadata !== "function") { + return; + } + try { + watchHistoryService.removeLocalMetadata(pointerKey); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to remove cached metadata for pointer:", + pointerKey, + error, + ); + } + } + } + + buildPointerFromDataset(dataset = {}) { + if (!dataset || typeof dataset !== "object") { + return null; + } + + const typeValue = typeof dataset.pointerType === "string" ? dataset.pointerType : ""; + const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : ""; + const value = + typeof dataset.pointerValue === "string" && dataset.pointerValue.trim() + ? dataset.pointerValue.trim() + : ""; + + if (!normalizedType || !value) { + return null; + } + + const pointer = { type: normalizedType, value }; + + if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) { + pointer.relay = dataset.pointerRelay.trim(); + } + + if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) { + const parsed = Number.parseInt(dataset.pointerWatchedAt, 10); + if (Number.isFinite(parsed)) { + pointer.watchedAt = parsed; + } + } + + if (dataset.pointerSession === "true") { + pointer.session = true; + } + + return pointer; + } + + async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) { + const pointer = this.buildPointerFromDataset(dataset); + const pointerKeyValue = + typeof dataset.pointerKey === "string" && dataset.pointerKey.trim() + ? dataset.pointerKey.trim() + : pointer + ? derivePointerKey(normalizePointerInput(pointer)) || "" + : ""; + + if (!pointerKeyValue) { + this.showError("Unable to determine which history entry to remove."); + return; + } + + const payload = { + removed: { + pointer, + pointerKey: pointerKeyValue, + }, + reason: dataset.reason || "remove-item", + }; + + let card = null; + if (trigger instanceof HTMLElement) { + card = trigger.closest(".video-card"); + if (card instanceof HTMLElement) { + card.dataset.historyRemovalPending = "true"; + card.classList.add("opacity-60"); + card.classList.add("pointer-events-none"); + } + } + + try { + await this.handleWatchHistoryRemoval(payload); + } catch (error) { + if (card instanceof HTMLElement) { + delete card.dataset.historyRemovalPending; + card.classList.remove("opacity-60", "pointer-events-none"); + } + if (!error?.handled) { + this.showError("Failed to remove from history. Please try again."); + } + return; + } + + if (card instanceof HTMLElement) { + delete card.dataset.historyRemovalPending; + card.classList.remove("opacity-60", "pointer-events-none"); + if (dataset.removeCard === "true") { + card.remove(); + } + } + } + + async handleWatchHistoryRemoval(payload = {}) { + if (!watchHistoryService?.isEnabled?.()) { + const error = new Error("watch-history-disabled"); + error.handled = true; + this.showError("Watch history sync is not available right now."); + throw error; + } + + const reason = + typeof payload.reason === "string" && payload.reason.trim() + ? payload.reason.trim() + : "remove-item"; + + const actorCandidate = + typeof payload.actor === "string" && payload.actor.trim() + ? payload.actor.trim() + : typeof this.pubkey === "string" && this.pubkey.trim() + ? this.pubkey.trim() + : typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey + ? nostrClient.sessionActor.pubkey + : ""; + + const removedPointerRaw = + payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null; + const removedPointerNormalized = normalizePointerInput(removedPointerRaw); + let removedPointerKey = + typeof payload?.removed?.pointerKey === "string" + ? payload.removed.pointerKey + : ""; + if (!removedPointerKey && removedPointerNormalized) { + removedPointerKey = derivePointerKey(removedPointerNormalized) || ""; + } + + if (removedPointerKey) { + this.dropWatchHistoryMetadata(removedPointerKey); + } + + const normalizeEntry = (entry) => { + if (!entry) { + return null; + } + const pointer = normalizePointerInput(entry.pointer || entry); + if (!pointer) { + return null; + } + if (Number.isFinite(entry.watchedAt)) { + pointer.watchedAt = Math.floor(entry.watchedAt); + } else if (Number.isFinite(entry.pointer?.watchedAt)) { + pointer.watchedAt = Math.floor(entry.pointer.watchedAt); + } + if (entry.pointer?.session === true || entry.session === true) { + pointer.session = true; + } + return pointer; + }; + + let normalizedItems = null; + + if (Array.isArray(payload.items) && payload.items.length) { + normalizedItems = payload.items.map(normalizeEntry).filter(Boolean); + } + + if (!normalizedItems) { + try { + const latest = await watchHistoryService.loadLatest(actorCandidate); + normalizedItems = Array.isArray(latest) + ? latest.map(normalizeEntry).filter(Boolean) + : []; + } catch (error) { + this.showError("Failed to load watch history. Please try again."); + if (error && typeof error === "object") { + error.handled = true; + } + throw error; + } + } + + if (removedPointerKey) { + normalizedItems = normalizedItems.filter((entry) => { + try { + return derivePointerKey(entry) !== removedPointerKey; + } catch (error) { + return true; + } + }); + } + + this.showSuccess("Removing from history…"); + + try { + const snapshotResult = await watchHistoryService.snapshot(normalizedItems, { + actor: actorCandidate || undefined, + reason, + }); + + try { + await nostrClient.updateWatchHistoryList(normalizedItems, { + actorPubkey: actorCandidate || undefined, + replace: true, + source: reason, + }); + } catch (updateError) { + if (isDevMode) { + console.warn( + "[watchHistory] Failed to update local watch history list:", + updateError, + ); + } + } + + this.showSuccess( + "Removed from encrypted history. Relay sync may take a moment.", + ); + + return { handledToasts: true, snapshot: snapshotResult }; + } catch (error) { + let message = "Failed to remove from history. Please try again."; + if (error?.result?.retryable) { + message = + "Removal will retry once encrypted history is accepted by your relays."; + } + this.showError(message); + if (error && typeof error === "object") { + error.handled = true; + } + throw error; + } + } + + 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) { + 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) { @@ -6504,68 +7554,98 @@ 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; } - const THRESHOLD_SECONDS = 12; + const VIEW_THRESHOLD_SECONDS = 12; const state = { videoEl, pointer, pointerKey, + viewerIdentityKey, handlers: [], - timerId: null, - fired: false, + viewTimerId: null, + viewFired: false, }; - const finalize = () => { - if (state.fired) { + 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(); + state.viewFired = true; + clearTimer("view"); this.cancelPendingViewLogging(); const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } = state; (async () => { + let viewResult; try { - const result = await recordVideoView(thresholdPointer); - const viewOk = !!result?.view?.ok; - if (viewOk) { - this.loggedViewPointerKeys.add(thresholdPointerKey); - } 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 canUseWatchHistoryService = + typeof watchHistoryService?.publishView === "function"; + const resolveWatchActor = () => { + const normalizedUser = this.normalizeHexPubkey(this.pubkey); + if (normalizedUser) { + return normalizedUser; } - 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." + const normalizedClient = this.normalizeHexPubkey( + nostrClient?.pubkey ); + if (normalizedClient) { + return normalizedClient; + } + + const normalizedSession = this.normalizeHexPubkey( + nostrClient?.sessionActor?.pubkey + ); + if (normalizedSession) { + return normalizedSession; + } + + if ( + typeof nostrClient?.pubkey === "string" && + nostrClient.pubkey.trim() + ) { + return nostrClient.pubkey.trim().toLowerCase(); + } + + if ( + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey.trim() + ) { + return nostrClient.sessionActor.pubkey.trim().toLowerCase(); + } + + return ""; + }; + + const activeWatchActor = resolveWatchActor(); + const watchMetadata = activeWatchActor ? { actor: activeWatchActor } : undefined; + + if (canUseWatchHistoryService) { + viewResult = await watchHistoryService.publishView( + thresholdPointer, + undefined, + watchMetadata + ); + } else if (typeof nostrClient?.recordVideoView === "function") { + viewResult = await nostrClient.recordVideoView(thresholdPointer); + } else { + viewResult = { ok: false, error: "view-logging-unavailable" }; } } catch (error) { if (isDevMode) { @@ -6574,24 +7654,32 @@ class bitvidApp { error ); } - try { - const fallbackHistory = await updateWatchHistoryList( - thresholdPointer - ); - 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 - ); - } + } + + 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 + ); } })().catch((error) => { if (isDevMode) { @@ -6603,15 +7691,10 @@ class bitvidApp { }); }; - 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) @@ -6619,13 +7702,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) => { @@ -6634,14 +7717,14 @@ class bitvidApp { }; registerHandler("timeupdate", () => { - if (videoEl.currentTime >= THRESHOLD_SECONDS) { - finalize(); + if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) { + finalizeView(); } }); const cancelOnPause = () => { - if (!state.fired) { - cancelTimer(); + if (!state.viewFired) { + clearTimer("view"); } }; @@ -6650,9 +7733,10 @@ class bitvidApp { ); const resumeIfNeeded = () => { - if (!state.fired && !state.timerId) { - scheduleTimer(); + if (state.viewFired) { + return; } + scheduleTimer("view", VIEW_THRESHOLD_SECONDS, finalizeView); }; ["play", "playing"].forEach((event) => @@ -7084,6 +8168,7 @@ class bitvidApp { // 1) Clear intervals, cleanup, etc. (unchanged) this.cancelPendingViewLogging(); this.clearActiveIntervals(); + this.teardownModalViewCountSubscription(); try { await fetch("/webtorrent/cancel/", { mode: "no-cors" }); @@ -7158,15 +8243,19 @@ class bitvidApp { this.videoSubscription = null; } + if (this.videoList) { + // Always reset the cached signature before showing the loading state so the + // next render isn't skipped when the incoming payload matches the last + // successful render. Without this, the spinner could remain visible for + // returning users who already have the latest list cached. + 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 = ` -

- Loading videos as they arrive... -

`; - } // Create a new subscription this.videoSubscription = nostrClient.subscribeVideos(() => { @@ -7519,6 +8608,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) => { @@ -7668,6 +8791,47 @@ class bitvidApp { ` : ""; + const pointerInfo = this.deriveVideoPointerInfo(video); + if (pointerInfo) { + this.persistWatchHistoryMetadataForVideo(video, pointerInfo); + } + + const pointerKeyAttr = pointerInfo?.key + ? this.escapeHTML(pointerInfo.key) + : ""; + const pointerTypeAttr = + pointerInfo?.pointer?.[0] === "a" + ? "a" + : pointerInfo?.pointer?.[0] === "e" + ? "e" + : ""; + const pointerValueAttr = pointerInfo?.pointer?.[1] + ? this.escapeHTML(pointerInfo.pointer[1]) + : ""; + const pointerRelayAttr = + pointerInfo?.pointer?.length > 2 && pointerInfo.pointer[2] + ? this.escapeHTML(pointerInfo.pointer[2]) + : ""; + const relayAttr = pointerRelayAttr + ? ` data-pointer-relay="${pointerRelayAttr}"` + : ""; + const removeHistoryMenuItem = + pointerKeyAttr && pointerTypeAttr && pointerValueAttr + ? ` + ` + : ""; + const moreMenu = `
+ ${removeHistoryMenuItem} ${blacklistMenuItem} - -
-
- - 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 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; } - - 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); + 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); + } + } + } - window.app?.attachVideoListHandler?.(); + return null; +} - const lazyEls = container.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); +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; +} - const authorPics = container.querySelectorAll(".author-pic"); - const authorNames = container.querySelectorAll(".author-name"); - const creatorButtons = container.querySelectorAll( - ".watch-history-card__creatorAvatar" +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"; + if (item.pointer?.type) { + removeButton.dataset.pointerType = item.pointer.type; + } + if (item.pointer?.value) { + removeButton.dataset.pointerValue = item.pointer.value; + } + if (item.pointer?.relay) { + removeButton.dataset.pointerRelay = item.pointer.relay; + } + if (Number.isFinite(item.watchedAt)) { + removeButton.dataset.pointerWatchedAt = String(item.watchedAt); + } + if (item.pointer?.session === true) { + removeButton.dataset.pointerSession = "true"; + } + removeButton.setAttribute( + "aria-label", + "Remove this encrypted history entry (sync may take a moment).", ); + removeButton.title = + "Removes this entry from encrypted history. Relay sync may take a moment."; - 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")); - }); + actions.appendChild(playButton); + actions.appendChild(channelButton); + actions.appendChild(removeButton); - if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { - window.app.batchFetchProfiles(localAuthorSet); + 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); } - 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); - }); - } - }); + article.appendChild(primary); + article.appendChild(meta); - 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); - }); - } - }); + return article; } 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, + fetchHistory = watchHistoryService.loadLatest.bind(watchHistoryService), + snapshot = watchHistoryService.snapshot.bind(watchHistoryService), getActor, - resolveBatch, - renderGrid, - } = mergedConfig; - - const providedBeforeInitialLoad = mergedConfig.beforeInitialLoad; - const providedGetSnapshotFingerprint = mergedConfig.getSnapshotFingerprint; - - 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; + 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, + featureBannerSelector = "#profileHistoryFeatureBanner", + toastRegionSelector = "#profileHistoryToastRegion", + sessionWarningSelector = "#profileHistorySessionWarning", + metadataToggleSelector = "#profileHistoryMetadataToggle", + metadataThumbSelector = "#profileHistoryMetadataThumb", + metadataLabelSelector = "#profileHistoryMetadataLabel", + metadataDescriptionSelector = "#profileHistoryMetadataDescription", + emptyCopy = WATCH_HISTORY_EMPTY_COPY, + disabledCopy = WATCH_HISTORY_DISABLED_COPY, + batchSize = WATCH_HISTORY_BATCH_SIZE, + remove = (payload) => { + const app = getAppInstance(); + if (app?.handleWatchHistoryRemoval) { + return app.handleWatchHistoryRemoval(payload); } - if (typeof console.log === "function") { - console.log(LOG_PREFIX, ...args); - } - } - }; + return defaultRemoveHandler(payload); + }, + } = config; - 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); - }; + const syncEnabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled() === true + : false; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory() === true + : false; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly() === true + : false; const state = { - isLoading: false, - hasMore: true, - observer: null, - scrollListener: null, - resolvedVideos: [], initialized: false, - emptyCopy, - batchSize: Math.max(1, toNumber(batchSize, BATCH_SIZE)), 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; - }; - - 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) { - if (nextMessage === WATCH_HISTORY_LOADING_STATUS) { - statusEl.innerHTML = - '' + - WATCH_HISTORY_LOADING_STATUS + - ""; - } else { - statusEl.textContent = nextMessage; - } - 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 }); - }; - - 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()); - applyAccessFilters(); - }; - - 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(); - 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 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, - }); - } - return; - } - - mergeResolvedVideos(videos); - renderResolvedVideos(); - setLoadingVisible(false); - 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 attachObservers = () => { - const { sentinel, scrollContainer } = getElements(); - if (!sentinel || !state.hasMore) { - debugLog("skipping observer attachment", { - hasSentinel: Boolean(sentinel), - hasMore: state.hasMore, - }); - return; - } - - 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; - } - - 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), - }); - }; - - 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; - } - - 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, - }); - }; - - return { - async init() { - debugLog("init called"); - await runInitialLoad(); + items: [], + fingerprint: "", + cursor: 0, + hasMore: false, + isLoading: false, + isRendering: false, + lastError: null, + observer: null, + observerAttached: false, + metadataPreference: "encrypted-only", + privacyDismissed: false, + metadataCache: new Map(), + catalogCaches: { + activeVideos: null, + catalogPromise: null, }, - async ensureInitialLoad() { - const { view } = getElements(); - if (!view) { - debugLog("ensureInitialLoad aborted: view not found"); - return; - } + metadataStorageEnabled: + typeof watchHistoryService.shouldStoreMetadata === "function" + ? watchHistoryService.shouldStoreMetadata() !== false + : true, + sessionFallbackActive: false, + syncEnabled, + localSupported, + localOnly, + featureEnabled: syncEnabled || localSupported, + }; - if (!state.initialized) { - debugLog("ensureInitialLoad rerunning initial load (not initialized)"); - await runInitialLoad(); - return; - } + let elements = { + view: null, + grid: null, + 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, + featureBanner: null, + toastRegion: null, + sessionWarning: null, + metadataToggle: null, + metadataThumb: null, + metadataLabel: null, + metadataDescription: null, + }; - state.actor = (await resolveActor(state.actor)) || null; + let boundGridClickHandler = null; + let boundLoadMoreHandler = null; + let boundClearHandler = null; + let boundRepublishHandler = null; + let boundPrivacyToggleHandler = null; + let boundPrivacyDismissHandler = null; + let boundMetadataToggleHandler = 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, - }); + const subscriptions = new Set(); + const toastTimers = new Set(); - 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) { + 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), + featureBanner: resolveElement(featureBannerSelector), + toastRegion: resolveElement(toastRegionSelector), + sessionWarning: resolveElement(sessionWarningSelector), + metadataToggle: resolveElement(metadataToggleSelector), + metadataThumb: resolveElement(metadataThumbSelector), + metadataLabel: resolveElement(metadataLabelSelector), + metadataDescription: resolveElement(metadataDescriptionSelector), + }; + } + + function clearSubscriptions() { + for (const unsubscribe of subscriptions) { + try { + unsubscribe(); + } catch (error) { + if (isDevEnv) { console.warn( - "[historyView] Failed to refresh watch history snapshot:", - error + "[historyView] Failed to unsubscribe from watch history event:", + error, ); } - } else { - debugLog("ensureInitialLoad skipping snapshot refresh (still loading)", { - actor: state.actor, - }); } + } + subscriptions.clear(); + } - 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 }); + function clearToasts() { + for (const timer of toastTimers) { + clearTimeout(timer); + } + toastTimers.clear(); + if (elements.toastRegion instanceof HTMLElement) { + elements.toastRegion.innerHTML = ""; + } + } - 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, - }); + function pushToast({ message, variant = "info", duration = 8000 }) { + if (!(elements.toastRegion instanceof HTMLElement)) { + return; + } + const text = typeof message === "string" ? message.trim() : ""; + if (!text) { + return; + } + const variantClass = { + info: "border-blue-500/40 bg-blue-500/10 text-blue-100", + warning: "border-amber-500/40 bg-amber-500/10 text-amber-100", + error: "border-red-500/40 bg-red-500/10 text-red-100", + }[variant] || "border-gray-600 bg-gray-800/70 text-gray-100"; + + if (elements.toastRegion.childElementCount >= 3) { + const firstChild = elements.toastRegion.firstElementChild; + if (firstChild instanceof HTMLElement) { + firstChild.remove(); } + } + + const toast = document.createElement("div"); + toast.className = `rounded-md border px-4 py-2 text-sm ${variantClass}`; + toast.setAttribute("role", "status"); + toast.textContent = text; + elements.toastRegion.appendChild(toast); + + const timeout = Number.isFinite(duration) ? Math.max(0, duration) : 8000; + if (timeout > 0) { + const timer = setTimeout(() => { + toast.remove(); + toastTimers.delete(timer); + }, timeout); + toastTimers.add(timer); + } + } + + function updateFeatureBanner() { + const actor = typeof state.actor === "string" ? state.actor : undefined; + const syncEnabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) === true + : false; + const localSupported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory(actor) === true + : false; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly(actor) === true + : false; + state.syncEnabled = syncEnabled; + state.localSupported = localSupported; + state.localOnly = localOnly; + state.featureEnabled = syncEnabled || localSupported; + if (!(elements.featureBanner instanceof HTMLElement)) { + return; + } + if (syncEnabled) { + elements.featureBanner.textContent = ""; + setHidden(elements.featureBanner, true); + return; + } + if (localOnly) { + elements.featureBanner.textContent = + "Watch history sync requires a logged-in Nostr account. This guest session is stored locally only."; + setHidden(elements.featureBanner, false); + return; + } + if (localSupported) { + elements.featureBanner.textContent = + "Watch history sync is disabled right now. We'll keep a local copy on this device."; + } else { + elements.featureBanner.textContent = + "Watch history is disabled on this server."; + } + setHidden(elements.featureBanner, false); + } + + function updateMetadataToggle() { + if (!(elements.metadataToggle instanceof HTMLElement)) { + return; + } + const enabled = state.metadataStorageEnabled; + elements.metadataToggle.setAttribute("aria-checked", enabled ? "true" : "false"); + elements.metadataToggle.classList.toggle("bg-blue-600", enabled); + elements.metadataToggle.classList.toggle("border-blue-500", enabled); + elements.metadataToggle.classList.toggle("bg-gray-700", !enabled); + elements.metadataToggle.classList.toggle("border-gray-600", !enabled); + if (elements.metadataThumb instanceof HTMLElement) { + elements.metadataThumb.classList.toggle("translate-x-5", enabled); + elements.metadataThumb.classList.toggle("translate-x-1", !enabled); + } + } + + function updateSessionFallbackWarning() { + if (!(elements.sessionWarning instanceof HTMLElement)) { + return; + } + const nip07Pubkey = + typeof nostrClient?.pubkey === "string" ? nostrClient.pubkey : ""; + const sessionPubkey = + typeof nostrClient?.sessionActor?.pubkey === "string" + ? nostrClient.sessionActor.pubkey + : ""; + const activeActor = typeof state.actor === "string" ? state.actor : ""; + const fallbackActive = !nip07Pubkey && sessionPubkey && sessionPubkey === activeActor; + state.sessionFallbackActive = Boolean(fallbackActive); + setHidden(elements.sessionWarning, !fallbackActive); + } + + function subscribeToMetadataPreference() { + if (typeof watchHistoryService.subscribe !== "function") { + return; + } + const unsubscribe = watchHistoryService.subscribe( + "metadata-preference", + (payload) => { + const enabled = payload?.enabled !== false; + state.metadataStorageEnabled = enabled; + updateMetadataToggle(); + } + ); + if (typeof unsubscribe === "function") { + subscriptions.add(unsubscribe); + } + } + + function subscribeToRepublishEvents() { + if (typeof watchHistoryService.subscribe !== "function") { + return; + } + const unsubscribe = watchHistoryService.subscribe( + "republish-scheduled", + (payload) => { + if (!payload) { + return; + } + if ( + payload.actor && + state.actor && + payload.actor !== state.actor + ) { + return; + } + let message = "Republish retry scheduled."; + const delayMs = Number.isFinite(payload.delayMs) + ? Math.max(0, Math.floor(payload.delayMs)) + : null; + if (delayMs != null) { + const seconds = Math.max(1, Math.round(delayMs / 1000)); + const suffix = seconds === 1 ? "" : "s"; + message = `Republish retry scheduled in about ${seconds} second${suffix}.`; + } + pushToast({ message, variant: "warning" }); + } + ); + if (typeof unsubscribe === "function") { + subscriptions.add(unsubscribe); + } + } + + function setLoadingState(loading) { + state.isLoading = loading; + if (elements.loading) { + setHidden(elements.loading, !loading); + } + if (elements.status) { + setHidden(elements.status, !loading); + } + } + + 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); + } + if (elements.empty) { + setTextContent(elements.empty, emptyCopy); + setHidden(elements.empty, false); + } + setHidden(elements.loadMore, true); + if (elements.sentinel) { + setHidden(elements.sentinel, true); + } + } + + function showFeatureDisabledState() { + hideErrorBanner(); + if (elements.loading) { + setHidden(elements.loading, true); + } + if (elements.status) { + setHidden(elements.status, true); + } + detachObserver(); + if (elements.grid) { + elements.grid.innerHTML = ""; + setHidden(elements.grid, true); + } + if (elements.empty) { + setTextContent(elements.empty, disabledCopy || emptyCopy); + setHidden(elements.empty, false); + } + if (elements.loadMore) { + setHidden(elements.loadMore, true); + } + if (elements.sentinel) { + setHidden(elements.sentinel, true); + } + } + + 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, + } + ); + } + + 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; + } + const pointerKeyValue = item.pointerKey; + let video = null; + let profile = null; + if ( + pointerKeyValue && + typeof watchHistoryService.getLocalMetadata === "function" + ) { + try { + const stored = watchHistoryService.getLocalMetadata(pointerKeyValue); + if (stored) { + video = stored.video || null; + profile = stored.profile || null; + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to read stored metadata for pointer:", + pointerKeyValue, + error, + ); + } + } + } + try { + const resolvedVideo = await resolveVideoFromPointer(item.pointer, caches); + if (resolvedVideo) { + video = resolvedVideo; + } + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to resolve video for pointer:", + error, + ); + } + } + if (video?.pubkey) { + profile = resolveProfileForPubkey(video.pubkey) || profile; + } + const metadata = { video: video || null, profile: profile || null }; + state.metadataCache.set(pointerKeyValue, metadata); + if (pointerKeyValue) { + if (state.metadataStorageEnabled) { + try { + watchHistoryService.setLocalMetadata?.(pointerKeyValue, metadata); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to persist metadata for pointer:", + pointerKeyValue, + error, + ); + } + } + } else { + try { + watchHistoryService.removeLocalMetadata?.(pointerKeyValue); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to remove cached metadata for pointer:", + pointerKeyValue, + error, + ); + } + } + } + } + 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; + 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(); + try { + watchHistoryService.clearLocalMetadata?.(); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to clear stored metadata while resetting progress:", + error, + ); + } + } + 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); + } + } + + function bindMetadataToggle() { + if (!(elements.metadataToggle instanceof HTMLElement)) { + return; + } + if (boundMetadataToggleHandler) { + elements.metadataToggle.removeEventListener( + "click", + boundMetadataToggleHandler + ); + } + boundMetadataToggleHandler = (event) => { + event.preventDefault(); + const nextValue = !state.metadataStorageEnabled; + try { + if (typeof watchHistoryService.setMetadataPreference === "function") { + watchHistoryService.setMetadataPreference(nextValue); + } + } catch (error) { + if (isDevEnv) { + console.warn("[historyView] Failed to set metadata preference:", error); + } + } + state.metadataStorageEnabled = + typeof watchHistoryService.shouldStoreMetadata === "function" + ? watchHistoryService.shouldStoreMetadata() !== false + : nextValue; + updateMetadataToggle(); + if (!state.metadataStorageEnabled) { + try { + watchHistoryService.clearLocalMetadata?.(); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to clear stored metadata after disabling preference:", + error, + ); + } + } + state.metadataCache.clear(); + void renderer.render(); + } + }; + elements.metadataToggle.addEventListener("click", boundMetadataToggleHandler); + } + + async function loadHistory({ force = false, actorOverride } = {}) { + if (state.isLoading) { + return false; + } + if (!state.featureEnabled) { + state.actor = null; + updateSessionFallbackWarning(); + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return false; + } + setLoadingState(true); + const actor = + typeof actorOverride === "string" && actorOverride.trim() + ? actorOverride.trim() + : await resolveActorKey(); + state.actor = actor; + updateSessionFallbackWarning(); + 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(options = {}) { + if (typeof document === "undefined") { + return; + } + clearSubscriptions(); + clearToasts(); + refreshElements(); + updateFeatureBanner(); + ensureObserver(); + bindGridEvents(); + bindLoadMore(); + bindActions(); + bindPrivacyControls(); + bindMetadataToggle(); + subscribeToMetadataPreference(); + subscribeToRepublishEvents(); + updateInfoCallout(); + state.metadataStorageEnabled = + typeof watchHistoryService.shouldStoreMetadata === "function" + ? watchHistoryService.shouldStoreMetadata() !== false + : true; + updateMetadataToggle(); + 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; + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } + await this.refresh({ ...options, force: true }); + }, + async ensureInitialLoad(options = {}) { + if (!state.initialized) { + await this.init(options); + return; + } + refreshElements(); + updateFeatureBanner(); + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } + bindMetadataToggle(); + state.metadataStorageEnabled = + typeof watchHistoryService.shouldStoreMetadata === "function" + ? watchHistoryService.shouldStoreMetadata() !== false + : true; + updateMetadataToggle(); + }, + async refresh(options = {}) { + updateFeatureBanner(); + if (!state.featureEnabled) { + state.items = []; + state.fingerprint = ""; + state.cursor = 0; + state.hasMore = false; + state.lastError = null; + showFeatureDisabledState(); + return; + } + const { actor, force = true } = options; + const changed = await loadHistory({ + force, + actorOverride: actor, + }); + if (!changed && state.items.length) { + updateLoadMoreVisibility(); + return; + } + await renderInitial(); }, async loadMore() { - debugLog("loadMore called"); - await loadNextBatch(); + if (!state.featureEnabled) { + return []; + } + if (!state.items.length) { + return []; + } + await renderNextBatch(); + return state.items.slice(0, state.cursor); }, resume() { - cleanupObservers(); - if (state.hasMore) { - debugLog("resume attaching observers"); - attachObservers(); - } + updateLoadMoreVisibility(); }, pause() { - debugLog("pause called"); - cleanupObservers(); + detachObserver(); }, destroy() { - debugLog("destroy called"); - cleanupObservers(); - cleanupGridHandler(); + 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.metadataToggle && boundMetadataToggleHandler) { + elements.metadataToggle.removeEventListener( + "click", + boundMetadataToggleHandler + ); + boundMetadataToggleHandler = null; + } + clearSubscriptions(); + clearToasts(); + if (elements.grid) { + elements.grid.innerHTML = ""; + } state.initialized = false; - state.resolvedVideos = []; - state.hasMore = true; - state.isLoading = false; - state.actor = null; - state.snapshotFingerprint = null; - resetUiState(); - setLoadingVisible(false); + state.items = []; + state.cursor = 0; + state.hasMore = false; + state.metadataCache.clear(); }, - render() { - renderResolvedVideos(); + 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(); + } + state.metadataCache.delete(pointerKeyValue); + try { + watchHistoryService.removeLocalMetadata?.(pointerKeyValue); + } catch (error) { + if (isDevEnv) { + console.warn( + "[historyView] Failed to drop cached metadata for pointer:", + pointerKeyValue, + error, + ); + } + } + 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(payload); + const result = await handler({ + actor, + items: remainingItems, + removed, + pointerKey: pointerKeyValue, + snapshot, + reason: "remove-item", + }); + const app = getAppInstance(); + if (!result?.handledToasts) { + app?.showSuccess?.("Removed from watch history."); + } + } catch (error) { + const app = getAppInstance(); + if (!error?.handled) { + 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(); @@ -1340,7 +1955,8 @@ export async function initHistoryView() { await watchHistoryRenderer.init(); } -if (!window.bitvid) { - window.bitvid = {}; +if (typeof window !== "undefined") { + window.bitvid = window.bitvid || {}; + window.bitvid.initHistoryView = initHistoryView; + window.bitvid.watchHistoryRenderer = watchHistoryRenderer; } -window.bitvid.initHistoryView = initHistoryView; diff --git a/js/index.js b/js/index.js index b6ac042c..7f623e7b 100644 --- a/js/index.js +++ b/js/index.js @@ -257,6 +257,10 @@ 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") { + } + handleHashChange(); } @@ -362,37 +366,58 @@ 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 '&' + if (typeof viewName === "string" && viewName.toLowerCase() === "history") { + } + 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(); - } - }); - }); } 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); -})(); 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..2d347cf6 --- /dev/null +++ b/js/magnetShared.js @@ -0,0 +1,345 @@ +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; + +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; + } + + const candidate = safeDecodeURIComponent(decoded); + if (!candidate || candidate === decoded) { + break; + } + decoded = candidate.trim(); + } + + 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 = safeDecodeURIComponentLoose(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 = safeDecodeURIComponentLoose(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/js/nostr.js b/js/nostr.js index a12fc112..621fb452 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2,30 +2,54 @@ 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_BATCH_PAGE_SIZE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, } 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"; import { extractMagnetHints } from "./magnet.js"; +import { + buildVideoPostEvent, + buildVideoMirrorEvent, + buildViewEvent, + buildWatchHistoryIndexEvent, + buildWatchHistoryChunkEvent, + getNostrEventSchema, + NOTE_TYPES, +} from "./nostrEventSchemas.js"; +import { + publishEventToRelay, + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.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"; @@ -34,7 +58,44 @@ 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_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); +const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind) + ? VIEW_EVENT_SCHEMA.kind + : 30079; + +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; +} // To limit error spam let errorLogCount = 0; @@ -133,6 +194,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 ""; @@ -175,6 +265,10 @@ function clonePointerItem(pointer) { cloned.watchedAt = watchedAt; } + if (pointer.session === true) { + cloned.session = true; + } + return cloned; } @@ -278,6 +372,316 @@ function normalizePointerInput(pointer) { return { type, value: trimmed, relay: null }; } +function resolveVideoViewPointer(pointer) { + const normalized = normalizePointerInput(pointer); + if (!normalized || typeof normalized.value !== "string") { + throw new Error("Invalid video pointer supplied for view lookup."); + } + + const value = normalized.value.trim(); + if (!value) { + throw new Error("Invalid video pointer supplied for view lookup."); + } + + const type = normalized.type === "a" ? "a" : "e"; + const descriptor = { type, value }; + + if (typeof normalized.relay === "string" && normalized.relay.trim()) { + descriptor.relay = normalized.relay.trim(); + } + + return descriptor; +} + +function createVideoViewEventFilters(pointer) { + let resolved; + + if ( + pointer && + typeof pointer === "object" && + (pointer.type === "a" || pointer.type === "e") && + typeof pointer.value === "string" + ) { + const value = pointer.value.trim(); + if (!value) { + throw new Error("Invalid video pointer supplied for view lookup."); + } + resolved = { type: pointer.type === "a" ? "a" : "e", value }; + if (typeof pointer.relay === "string" && pointer.relay.trim()) { + resolved.relay = pointer.relay.trim(); + } + } else { + resolved = resolveVideoViewPointer(pointer); + } + + const pointerFilter = { + kinds: [VIEW_EVENT_KIND], + "#t": ["view"], + }; + + if (resolved.type === "a") { + pointerFilter["#a"] = [resolved.value]; + } else { + pointerFilter["#e"] = [resolved.value]; + } + + const filters = [pointerFilter]; + + if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) { + filters.push({ + kinds: [VIEW_EVENT_KIND], + "#t": ["view"], + "#video": [resolved.value], + }); + } + + return { pointer: resolved, filters }; +} + +function deriveViewEventBucketIndex(createdAtSeconds) { + const timestamp = Number.isFinite(createdAtSeconds) + ? Math.floor(createdAtSeconds) + : Math.floor(Date.now() / 1000); + const windowSize = Math.max( + 1, + Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0 + ); + return Math.floor(timestamp / windowSize); +} + +function getViewEventGuardWindowMs() { + const windowSeconds = Math.max( + 1, + Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0 + ); + return windowSeconds * 1000; +} + +function deriveViewEventPointerScope(pointer) { + const pointerValue = + typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : ""; + if (!pointerValue) { + return ""; + } + const pointerType = pointer?.type === "a" ? "a" : "e"; + return `${pointerType}:${pointerValue}`; +} + +function generateViewEventEntropy() { + const cryptoRef = + (typeof globalThis !== "undefined" && + /** @type {Crypto | undefined} */ (globalThis.crypto)) || + null; + + if (cryptoRef && typeof cryptoRef.getRandomValues === "function") { + try { + const buffer = new Uint32Array(2); + cryptoRef.getRandomValues(buffer); + return Array.from(buffer, (value) => + value.toString(16).padStart(8, "0") + ).join(""); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to gather crypto entropy for view event:", error); + } + } + } + + const fallbackA = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0"); + const fallbackB = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0"); + return `${fallbackA}${fallbackB}`; +} + +function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) { + const scope = deriveViewEventPointerScope(pointer) || "unknown"; + const normalizedActor = + typeof actorPubkey === "string" && actorPubkey.trim() + ? actorPubkey.trim().toLowerCase() + : "anon"; + const timestamp = Number.isFinite(createdAtSeconds) + ? Math.max(0, Math.floor(createdAtSeconds)) + : Math.floor(Date.now() / 1000); + const entropy = generateViewEventEntropy(); + return `${scope}:${normalizedActor}:${timestamp}:${entropy}`; +} + +function hasRecentViewPublish(scope, bucketIndex) { + if (!scope || !Number.isFinite(bucketIndex)) { + return false; + } + + const windowMs = getViewEventGuardWindowMs(); + const now = Date.now(); + const entry = viewEventPublishMemory.get(scope); + + if (entry) { + const age = now - Number(entry.seenAt); + if (!Number.isFinite(entry.seenAt) || age >= windowMs) { + viewEventPublishMemory.delete(scope); + } else if (Number(entry.bucket) === bucketIndex) { + return true; + } + } + + if (typeof localStorage === "undefined") { + return false; + } + + const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`; + let rawValue = null; + try { + rawValue = localStorage.getItem(storageKey); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to read view guard entry:", error); + } + return false; + } + + if (typeof rawValue !== "string" || !rawValue) { + return false; + } + + const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2); + const storedBucket = Number(storedBucketRaw); + const storedSeenAt = Number(storedSeenRaw); + + if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) { + try { + localStorage.removeItem(storageKey); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to clear corrupt view guard entry:", error); + } + } + return false; + } + + if (now - storedSeenAt >= windowMs) { + try { + localStorage.removeItem(storageKey); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to remove expired view guard entry:", error); + } + } + return false; + } + + viewEventPublishMemory.set(scope, { + bucket: storedBucket, + seenAt: storedSeenAt, + }); + + return storedBucket === bucketIndex; +} + +function rememberViewPublish(scope, bucketIndex) { + if (!scope || !Number.isFinite(bucketIndex)) { + return; + } + + const now = Date.now(); + const windowMs = getViewEventGuardWindowMs(); + const entry = viewEventPublishMemory.get(scope); + if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) { + viewEventPublishMemory.delete(scope); + } + + viewEventPublishMemory.set(scope, { + bucket: bucketIndex, + seenAt: now, + }); + + if (typeof localStorage === "undefined") { + return; + } + + const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`; + try { + localStorage.setItem(storageKey, `${bucketIndex}:${now}`); + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to persist view guard entry:", error); + } + } +} + +function isVideoViewEvent(event, pointer) { + if (!event || typeof event !== "object") { + return false; + } + + if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) { + return false; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let hasViewTag = false; + let matchesPointer = false; + + const pointerValueRaw = + typeof pointer?.value === "string" ? pointer.value.trim() : ""; + const pointerValueLower = pointerValueRaw.toLowerCase(); + const pointerType = pointer?.type === "a" ? "a" : "e"; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + + const label = typeof tag[0] === "string" ? tag[0] : ""; + const value = typeof tag[1] === "string" ? tag[1].trim() : ""; + + if (!label) { + continue; + } + + if (!hasViewTag && label === "t" && value === "view") { + hasViewTag = true; + continue; + } + + if (matchesPointer || !pointerValueLower) { + continue; + } + + if ( + VIEW_FILTER_INCLUDE_LEGACY_VIDEO && + label === "video" && + value.toLowerCase() === pointerValueLower + ) { + matchesPointer = true; + continue; + } + + const pointerTag = normalizePointerTag(tag); + if (!pointerTag) { + continue; + } + + const tagValueLower = + typeof pointerTag.value === "string" + ? pointerTag.value.trim().toLowerCase() + : ""; + + if (!tagValueLower || tagValueLower !== pointerValueLower) { + continue; + } + + if (pointerTag.type === pointerType) { + matchesPointer = true; + continue; + } + } + + return hasViewTag && matchesPointer; +} + function extractPointerItemsFromEvent(event) { if (!event || typeof event !== "object") { return []; @@ -489,6 +893,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 ""; @@ -589,69 +1122,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") { - pub.on("ok", () => { - clearTimeout(timeoutId); - finalize(true); - }); - pub.on("seen", () => { - clearTimeout(timeoutId); - finalize(true); - }); - pub.on("failed", (reason) => { - clearTimeout(timeoutId); - const err = - reason instanceof Error - ? reason - : new Error(String(reason || "publish failed")); - finalize(false, err); - }); - 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", @@ -938,7 +1408,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(); @@ -952,8 +1424,252 @@ class NostrClient { this.watchHistoryCache = new Map(); this.watchHistoryStorage = null; this.watchHistoryRepublishTimers = new Map(); - this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; + this.watchHistoryRefreshPromises = new Map(); + this.watchHistoryCacheTtlMs = 0; + this.watchHistoryFingerprints = new Map(); + this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; + 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() + ? 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() { @@ -1055,987 +1771,1791 @@ class NostrClient { return this.allEvents.size > 0; } - /** - * Resolve the effective TTL for watch-history cache entries. - */ + 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); + } + getWatchHistoryCacheTtlMs() { - const candidate = this.watchHistoryCacheTtlMs; - if (Number.isFinite(candidate) && candidate >= 0) { - return candidate; + if (Number.isFinite(this.watchHistoryCacheTtlMs) && this.watchHistoryCacheTtlMs > 0) { + return this.watchHistoryCacheTtlMs; } - return WATCH_HISTORY_CACHE_TTL_MS; + + 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; } - /** - * Load watch history cache payload from localStorage. - */ getWatchHistoryStorage() { - const base = { version: 1, actors: {} }; + if (this.watchHistoryStorage && this.watchHistoryStorage.version === WATCH_HISTORY_STORAGE_VERSION) { + return this.watchHistoryStorage; + } - if (!this.watchHistoryStorage) { - if (typeof localStorage === "undefined") { - this.watchHistoryStorage = base; - 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; + } - 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); - } + 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); } - - const actors = { ...(parsed.actors || {}) }; - this.watchHistoryStorage = { version: 1, actors }; + 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; - 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]; + + 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, + }; } - if (mutated && typeof localStorage !== "undefined") { + const storage = { + version: WATCH_HISTORY_STORAGE_VERSION, + actors: sanitizedActors, + }; + + if (mutated || parsed?.version !== WATCH_HISTORY_STORAGE_VERSION) { try { - localStorage.setItem( - WATCH_HISTORY_CACHE_STORAGE_KEY, - JSON.stringify(this.watchHistoryStorage) - ); + localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storage)); } catch (error) { if (isDevMode) { - console.warn( - "[nostr] Failed to persist cleaned watch history cache:", - error - ); + console.warn("[nostr] Failed to rewrite watch history storage:", error); } } } + this.watchHistoryStorage = storage; return this.watchHistoryStorage; } - createWatchHistoryEntry(pointerEvent, items, savedAt, previousEntry = null) { - const normalizedItems = []; - const seen = new Set(); - - if (Array.isArray(items)) { - for (const raw of items) { - const pointer = normalizePointerInput(raw); - if (!pointer) { - continue; - } - const key = pointerKey(pointer); - if (!key || seen.has(key)) { - continue; - } - seen.add(key); - const watchedAtRaw = Number.isFinite(pointer.watchedAt) - ? pointer.watchedAt - : null; - const watchedAt = - watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0; - normalizedItems.push({ ...pointer, watchedAt }); - } - } - - const entry = { - pointerEvent: pointerEvent ? cloneEventForCache(pointerEvent) : null, - items: normalizedItems, - savedAt: typeof savedAt === "number" ? savedAt : Date.now(), - resolved: previousEntry?.resolved - ? new Map(previousEntry.resolved) - : new Map(), - resolvedVideos: previousEntry?.resolvedVideos - ? new Map(previousEntry.resolvedVideos) - : new Map(), - resolving: previousEntry?.resolving - ? new Set(previousEntry.resolving) - : new Set(), - delivered: previousEntry?.delivered instanceof Set - ? new Set(previousEntry.delivered) - : new Set(), - }; - - 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); - } - } - - const previousChunkPointers = previousEntry?.chunkPointers; - const headPointer = previousChunkPointers?.head - ? clonePointerItem(previousChunkPointers.head) - : null; - const chunkPointerList = Array.isArray(previousChunkPointers?.chunks) - ? previousChunkPointers.chunks - .map((pointer) => clonePointerItem(pointer)) - .filter(Boolean) - : []; - - entry.chunkPointers = { - head: headPointer, - chunks: chunkPointerList, - }; - - entry.chunkEvents = new Map(); - if (previousEntry?.chunkEvents instanceof Map) { - for (const [key, value] of previousEntry.chunkEvents.entries()) { - const clonedEvent = cloneEventForCache(value); - if (clonedEvent) { - entry.chunkEvents.set(key, clonedEvent); - } - } - } - - return entry; - } - - persistWatchHistoryEntry(actor, entry) { - if (typeof localStorage === "undefined" || !actor) { + persistWatchHistoryEntry(actorInput, entry) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { 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, - }; + const actors = { ...storage.actors }; + const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); + let mutated = false; - try { - localStorage.setItem( - WATCH_HISTORY_CACHE_STORAGE_KEY, - JSON.stringify(storage) - ); - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to persist watch history cache:", error); - } - } - } - - getWatchHistoryFingerprint(pubkeyOrSession) { - let actor = - typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() - ? pubkeyOrSession.trim() - : ""; - - if (!actor) { - if (this.sessionActor?.pubkey) { - actor = this.sessionActor.pubkey.trim(); - } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { - actor = this.pubkey.trim(); + 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; } } - if (!actor) { - return ""; - } - - const entry = this.watchHistoryCache.get(actor); if (!entry) { - return ""; - } - - const pointerId = - entry.pointerEvent && typeof entry.pointerEvent.id === "string" - ? entry.pointerEvent.id - : ""; - const pointerCreated = - entry.pointerEvent && Number.isFinite(entry.pointerEvent.created_at) - ? entry.pointerEvent.created_at - : 0; - const itemKeys = entry.items - .map((item) => pointerKey(item)) - .filter(Boolean) - .join("|"); - - return `${pointerId}:${pointerCreated}:${itemKeys}`; - } - - resetWatchHistoryProgress(pubkeyOrSession = null) { - let actor = - typeof pubkeyOrSession === "string" && pubkeyOrSession.trim() - ? pubkeyOrSession.trim() - : ""; - - if (!actor) { - if (this.sessionActor?.pubkey) { - actor = this.sessionActor.pubkey.trim(); - } else if (typeof this.pubkey === "string" && this.pubkey.trim()) { - actor = this.pubkey.trim(); + if (actors[actorKey]) { + delete actors[actorKey]; + mutated = true; } - } - - 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(); + 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; + + actors[actorKey] = { + actor: actorValue, + snapshotId, + fingerprint, + savedAt, + items, + metadata, + }; + mutated = true; } - if (entry.resolving instanceof Set) { - entry.resolving.clear(); - } - } - - async encryptWatchHistoryPayload(actorPubkey, payload) { - const normalizedActor = - typeof actorPubkey === "string" && actorPubkey.trim() - ? actorPubkey.trim().toLowerCase() - : ""; - if (!normalizedActor) { - return { ok: false, error: "invalid-actor" }; - } - - let normalizedPayload = payload; - if (!normalizedPayload || typeof normalizedPayload !== "object") { - normalizedPayload = {}; - } - - const items = Array.isArray(normalizedPayload.items) - ? normalizedPayload.items - : []; - - const version = Number.isFinite(normalizedPayload.version) - ? normalizedPayload.version - : 1; - - const prepared = { - version, - items, + const payload = { + version: WATCH_HISTORY_STORAGE_VERSION, + actors, }; - 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; - } + this.watchHistoryStorage = payload; - 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 normalizedActor = - typeof actorPubkey === "string" && actorPubkey.trim() - ? actorPubkey.trim().toLowerCase() - : ""; - if (!normalizedActor) { - return fallbackPayload; - } - - const ciphertext = - pointerEvent && typeof pointerEvent.content === "string" - ? pointerEvent.content.trim() - : ""; - - if (!ciphertext) { - return fallbackPayload; - } - - const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - - if ( - normalizedActor && - normalizedActor === normalizedLogged && - window?.nostr?.nip04?.decrypt - ) { - try { - const plaintext = await window.nostr.nip04.decrypt( - normalizedActor, - ciphertext - ); - const parsed = parseWatchHistoryPayload(plaintext); - if (parsed.items.length || fallbackItems.length === 0) { - return parsed; - } - return { ...parsed, items: fallbackItems }; - } catch (error) { - if (isDevMode) { - console.warn( - "[nostr] Failed to decrypt watch history with extension:", - error - ); - } - } + if (!mutated || typeof localStorage === "undefined") { + return; } try { - if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { - await this.ensureSessionActor(true); - } + localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); } catch (error) { if (isDevMode) { - console.warn( - "[nostr] Failed to ensure session actor while decrypting watch history:", - error - ); + console.warn("[nostr] Failed to persist watch history entry:", error); } } - - const privateKey = this.sessionActor?.privateKey; - if (privateKey && window?.NostrTools?.nip04?.decrypt) { - try { - const plaintext = await window.NostrTools.nip04.decrypt( - privateKey, - normalizedActor, - ciphertext - ); - const parsed = parseWatchHistoryPayload(plaintext); - if (parsed.items.length || fallbackItems.length === 0) { - return parsed; - } - return { ...parsed, items: fallbackItems }; - } 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() - : ""; + 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 timer = this.watchHistoryRepublishTimers.get(key); - if (timer) { - clearTimeout(timer); - this.watchHistoryRepublishTimers.delete(key); + 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); } - scheduleWatchHistoryRepublish(actor, items) { - if (!this.pool) { + scheduleWatchHistoryRepublish(snapshotId, operation, options = {}) { + const key = typeof snapshotId === "string" ? snapshotId.trim() : ""; + if (!key || typeof operation !== "function") { return; } - const key = - typeof actor === "string" && actor.trim() - ? actor.trim().toLowerCase() - : ""; - if (!key || this.watchHistoryRepublishTimers.has(key)) { + const onSchedule = + typeof options?.onSchedule === "function" ? options.onSchedule : null; + 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 clonedItems = Array.isArray(items) - ? items - .map((item) => clonePointerItem(item)) - .filter((candidate) => !!candidate) - : []; + 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), + ); - 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 (onSchedule) { + try { + onSchedule({ snapshotId: key, attempt: attempt + 1, delay }); + } catch (error) { if (isDevMode) { console.warn( - `[nostr] Failed to republish watch history list for ${key}:`, - error + `[nostr] Failed to notify watch history republish schedule for ${key}:`, + error, ); } - }); - }, 2000); - - this.watchHistoryRepublishTimers.set(key, timer); - } - - 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); - }); - - 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) { + const timer = setTimeout(async () => { + this.watchHistoryRepublishTimers.delete(key); 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"); + 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, + onSchedule, + }); + } else if (isDevMode) { + console.warn( + `[nostr] Watch history republish aborted for ${key}: max attempts reached.`, + ); + } + } else { + this.cancelWatchHistoryRepublish(key); } } catch (error) { - console.warn( - "[nostr] Failed to prepare signing key for watch history chunks:", - error - ); - return { ok: false, error: "signing-failed", details: 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, + onSchedule, + }); + } } + }, delay); + + this.watchHistoryRepublishTimers.set(key, { + timer, + attempt, + operation, + }); + + return { attempt: attempt + 1, delay }; + } + + 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 totalChunks = chunkItemsList.length || 1; - const chunkIdentifiers = chunkItemsList.map((_, index) => - index === 0 - ? WATCH_HISTORY_LIST_IDENTIFIER - : `watch-history:${snapshotId}:${index}` - ); - const chunkAddresses = chunkIdentifiers.map( - (identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}` - ); + 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 nowSeconds = Math.floor(Date.now() / 1000); - let lastCreatedAt = 0; + 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; + } - const pointerCandidates = []; - if (existingEntry?.pointerEvent) { - pointerCandidates.push(existingEntry.pointerEvent); + 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: "" }); } - const cachedPointerEvent = this.watchHistoryCache.get(normalizedActor)?.pointerEvent; - if (cachedPointerEvent) { - pointerCandidates.push(cachedPointerEvent); + if (this.watchHistoryRefreshPromises.has(actorKey)) { + return this.watchHistoryRefreshPromises.get(actorKey); } - for (const candidate of pointerCandidates) { - if (!candidate || typeof candidate !== "object") { - continue; + const promise = (async () => { + const fetchResult = await this.fetchWatchHistory(resolvedActor, { + forceRefresh: true, + }); + + if (fetchResult.pointerEvent) { + return fetchResult; } - 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 chunkResults = []; - let overallSuccess = true; + const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey]; + const metadata = sanitizeWatchHistoryMetadata(storageEntry?.metadata); + const alreadyAttempted = metadata.autoSnapshotAttempted === true; - for (let index = 0; index < totalChunks; index++) { - const chunkItems = Array.isArray(chunkItemsList[index]) - ? chunkItemsList[index] + const items = Array.isArray(storageEntry?.items) + ? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS) : []; - 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; + if (!items.length || alreadyAttempted) { + return fetchResult; } - const pointerTags = chunkItems.map((item) => { - const tag = [item.type, item.value]; - if (item.relay) { - tag.push(item.relay); + 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); + } + + console.info( + "[nostr] Preparing to publish watch history snapshot.", + { + actor: actorKey, + snapshotId, + itemCount: canonicalItems.length, + chunkCount: chunks.length, + relaysRequested: relays, + attempt: options.attempt || 0, + source: options.source || "unknown", + } + ); + + 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; + let anyChunkPartial = false; + + const formatRelayStatus = (results = []) => { + const normalized = Array.isArray(results) ? results : []; + const statuses = []; + const byUrl = new Map(); + + for (const entry of normalized) { + const url = typeof entry?.url === "string" ? entry.url : ""; + if (!url) { + continue; + } + const reasonValue = (() => { + const error = entry?.error; + if (!error) { + return null; + } + if (error instanceof Error) { + return error.message || "publish failed"; + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + try { + return JSON.stringify(error); + } catch (_) { + return String(error); + } + })(); + + byUrl.set(url, { + url, + success: !!entry?.success, + reason: reasonValue, + }); + } + + for (const relayUrl of relays) { + const existing = byUrl.get(relayUrl); + if (existing) { + statuses.push(existing); + } else { + statuses.push({ url: relayUrl, success: false, reason: "no-result" }); + } + } + + return statuses; + }; + + 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 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, - pubkey: normalizedActor, - created_at: baseTimestamp + (totalChunks - index - 1), - tags, - 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 }; + console.info( + "[nostr] Publishing watch history chunk.", + { + actor: actorKey, + snapshotId, + chunkIndex: index, + chunkSize: chunkItems.length, + relays, } - } 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 }; - } - } - - const publishResults = await Promise.all( - relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) ); - const chunkSuccess = publishResults.some((result) => result.success); - if (!chunkSuccess) { - overallSuccess = false; + 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 relayStatus = formatRelayStatus(publishResults); + const acceptedCount = relayStatus.filter((entry) => entry.success).length; + + if (acceptedCount === 0) { + anyChunkRejected = true; console.warn( - "[nostr] Failed to publish watch history chunk:", - publishResults + `[nostr] Watch history chunk ${index} rejected by all relays:`, + publishResults, ); + } else { + const logMessage = + acceptedCount === relays.length + ? "accepted" + : "partially accepted"; + if (acceptedCount === relays.length) { + console.info( + `[nostr] Watch history chunk ${index} accepted by ${acceptedCount}/${relays.length} relay(s).`, + ); + } else { + anyChunkPartial = true; + console.warn( + `[nostr] Watch history chunk ${index} ${logMessage} by ${acceptedCount}/${relays.length} relay(s).`, + publishResults, + ); + } + } + + const address = eventToAddressPointer(signedEvent); + if (address) { + chunkAddresses.push(address); } chunkResults.push({ - chunkIndex: index, event: signedEvent, - results: publishResults, - success: chunkSuccess, + publishResults, + acceptedCount, + relayStatus, }); } - if (overallSuccess) { - this.cancelWatchHistoryRepublish(normalizedActor); - } else if (allowRetry && persistedItems.length) { - this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); + 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 baselineEntry = - existingEntry || this.watchHistoryCache.get(normalizedActor) || null; - - const headEvent = chunkResults[0]?.event || null; - const newEntry = this.createWatchHistoryEntry( - headEvent, - persistedItems, - Date.now(), - baselineEntry + console.info( + "[nostr] Publishing watch history pointer event.", + { + actor: actorKey, + snapshotId, + relays, + } ); - this.watchHistoryCache.set(normalizedActor, newEntry); - this.persistWatchHistoryEntry(normalizedActor, newEntry); + const pointerResults = await publishEventToRelays( + this.pool, + relays, + signedPointerEvent, + ); + const pointerRelayStatus = formatRelayStatus(pointerResults); + const pointerAcceptedCount = pointerRelayStatus.filter((entry) => entry.success) + .length; + const pointerAccepted = pointerAcceptedCount > 0; - return { - ok: overallSuccess, - event: headEvent, - events: chunkResults.map((chunk) => chunk.event), - chunks: chunkResults, - results: chunkResults.map((chunk) => chunk.results), - snapshot: snapshotId, - items: newEntry.items - .map((item) => clonePointerItem(item)) - .filter(Boolean), + if (pointerAcceptedCount === relays.length) { + console.info( + `[nostr] Watch history pointer accepted by ${pointerAcceptedCount}/${relays.length} relay(s).`, + ); + } else if (pointerAccepted) { + console.warn( + `[nostr] Watch history pointer partially accepted by ${pointerAcceptedCount}/${relays.length} relay(s).`, + pointerResults, + ); + } else { + console.warn( + "[nostr] Watch history pointer rejected by all relays:", + pointerResults, + ); + } + + this.watchHistoryLastCreatedAt = createdAtCursor; + + const chunkStatuses = chunkResults.map((entry) => entry.relayStatus); + const chunkAcceptedEverywhere = chunkResults.every( + (entry) => entry.acceptedCount === relays.length, + ); + const chunkRejectedEverywhere = chunkResults.some( + (entry) => entry.acceptedCount === 0, + ); + const pointerRejectedEverywhere = pointerAcceptedCount === 0; + const pointerPartial = + pointerAccepted && pointerAcceptedCount < relays.length; + const partialAcceptance = pointerPartial || anyChunkPartial; + const success = + !pointerRejectedEverywhere && pointerAcceptedCount === relays.length && + chunkAcceptedEverywhere && + !anyChunkRejected; + + let errorCode = null; + if (!success) { + if (pointerRejectedEverywhere && chunkRejectedEverywhere) { + errorCode = "pointer-and-chunk-rejected"; + } else if (pointerRejectedEverywhere) { + errorCode = "pointer-rejected"; + } else if (chunkRejectedEverywhere || anyChunkRejected) { + errorCode = "chunk-rejected"; + } else if (partialAcceptance) { + errorCode = "partial-relay-acceptance"; + } else { + errorCode = "publish-rejected"; + } + } + + const result = { + 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), + relayStatus: { + pointer: pointerRelayStatus, + chunks: chunkStatuses, + }, + }, + skippedCount: skipped.length, + source: options.source || "manual", + partial: partialAcceptance, + }; + + if (!success && errorCode) { + result.error = errorCode; + } + + console.info("[nostr] Watch history snapshot publish result.", { + actor: actorKey, + snapshotId, + success, + partialAcceptance, + error: result.error || null, + pointerAcceptedCount, + chunkAcceptedCounts: chunkResults.map((entry) => entry.acceptedCount), + }); + + return result; + } + + 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, + ); + + console.info("[nostr] Updating watch history list.", { + actor: resolvedActor, + incomingItemCount: incomingItems.length, + finalItemCount: canonicalItems.length, + replace: options.replace === true, + }); + + const publishResult = await this.publishWatchHistorySnapshot( + canonicalItems, + { + actorPubkey: resolvedActor, + snapshotId: options.snapshotId || cachedEntry.snapshotId, + attempt: options.attempt || 0, + }, + ); + + console.info("[nostr] Watch history list publish attempt finished.", { + actor: resolvedActor, + snapshotId: publishResult.snapshotId || null, + success: !!publishResult.ok, + retryable: !!publishResult.retryable, + }); + + 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: "" }; + } + + console.info("[nostr] Fetching watch history from relays.", { + actor: resolvedActor, + forceRefresh: options.forceRefresh === true, + }); + + 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 + ) { + console.info("[nostr] Using cached watch history entry.", { + actor: resolvedActor, + itemCount: Array.isArray(existingEntry.items) ? existingEntry.items.length : 0, + cacheAgeMs: now - existingEntry.savedAt, + }); + return { + pointerEvent: existingEntry.pointerEvent || null, + items: existingEntry.items || [], + snapshotId: existingEntry.snapshotId || "", + }; + } + + if (!this.pool) { + console.warn("[nostr] Cannot fetch watch history because relay pool is unavailable. Returning cached values."); + return { + pointerEvent: existingEntry?.pointerEvent || null, + items: existingEntry?.items || [], + snapshotId: existingEntry?.snapshotId || "", + }; + } + + const 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) { + console.info( + "[nostr] No watch history pointer event found on relays. Falling back to storage.", + { + actor: resolvedActor, + } + ); + 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 []; + } + + console.info("[nostr] Resolving watch history for actor.", { + actor: resolvedActor, + forceRefresh: options.forceRefresh === true, + }); + + 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 shouldBatch = Boolean(WATCH_HISTORY_BATCH_RESOLVE); + const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE); + const hasCustomBatchSize = + Number.isFinite(batchPageSizeRaw) && batchPageSizeRaw > 0; + const batchLimit = shouldBatch && hasCustomBatchSize + ? Math.min(Math.floor(batchPageSizeRaw), WATCH_HISTORY_MAX_ITEMS) + : WATCH_HISTORY_MAX_ITEMS; + + const canonicalItems = canonicalizeWatchHistoryItems( + merged.items || [], + batchLimit, + ); + + const fingerprint = await this.getWatchHistoryFingerprint( + resolvedActor, + canonicalItems, + ); + + console.info("[nostr] Watch history fetch complete.", { + actor: resolvedActor, + snapshotId: fetchResult.snapshotId || null, + pointerFound: !!fetchResult.pointerEvent, + itemCount: canonicalItems.length, + }); + + console.info("[nostr] Watch history resolved and cached.", { + actor: resolvedActor, + itemCount: canonicalItems.length, + snapshotId: fetchResult.snapshotId || null, + }); + + 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 = {}) { + 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 ensureSessionActor(forceSession = false) { - const loggedInPubkey = - typeof this.pubkey === "string" && this.pubkey.trim() - ? this.pubkey.trim() - : ""; + async countVideoViewEvents(pointer, options = {}) { + const relayList = + Array.isArray(options?.relays) && options.relays.length + ? options.relays + : undefined; - if (!forceSession && loggedInPubkey) { - return loggedInPubkey; + 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, + }; } - if ( - this.sessionActor && - typeof this.sessionActor.pubkey === "string" && - this.sessionActor.pubkey - ) { - return this.sessionActor.pubkey; + 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."); } - 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 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); } } - 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 + 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; - if (!keyGenerator || !pubkeyDeriver) { - if (isDevMode) { - console.warn( - "[nostr] Unable to generate session actor: missing NostrTools helpers." - ); - } - return ""; - } + const listPromise = this.listVideoViewEvents(pointer, { + ...fallbackListOptions, + }); - 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 - ); + 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; } - privateKey = ""; - } - } else { - privateKey = ""; - } - } + return new Set(withIds).size; + })() + : 0; - 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; + return { + total: uniqueCount, + perRelay: [], + best: null, + fallback: true, + }; } async publishViewEvent(videoPointer, options = {}) { @@ -2058,6 +3578,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 = @@ -2071,28 +3606,69 @@ class NostrClient { ) : []; - const tags = [ - ["t", "view"], - ["video", pointer.value], - ...additionalTags, - ]; + const pointerTag = + pointer.type === "a" + ? pointer.relay + ? ["a", pointer.value, pointer.relay] + : ["a", pointer.value] + : pointer.relay + ? ["e", pointer.value, pointer.relay] + : ["e", pointer.value]; - if ( - usingSessionActor && - !tags.some((tag) => tag[0] === "session" && tag[1] === "true") + let content = ""; + if (typeof options.content === "string") { + content = options.content; + } else if ( + options.content && + typeof options.content === "object" && + !Array.isArray(options.content) ) { - tags.push(["session", "true"]); + try { + content = JSON.stringify(options.content); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to serialize custom view event content:", + error + ); + } + content = ""; + } } - const content = - typeof options.content === "string" ? options.content : ""; - const event = { - kind: WATCH_HISTORY_KIND, + 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, created_at: createdAt, - tags, + pointerValue: pointer.value, + pointerTag, + includeSessionTag: usingSessionActor, + additionalTags, content, - }; + }); let signedEvent = null; @@ -2135,975 +3711,56 @@ 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) { + if (guardScope) { + rememberViewPublish(guardScope, guardBucket); + } + console.info( + `[nostr] View event accepted by ${acceptedRelays.length} relay(s):`, + acceptedRelays.join(", ") + ); + } else { console.warn("[nostr] View event rejected by relays:", publishResults); } - return { ok: success, event: signedEvent, results: publishResults }; - } - - 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) { - 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); - if (cached) { - if (now - cached.savedAt < ttl) { - 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 ( - 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); - return { - pointerEvent: entry.pointerEvent - ? cloneEventForCache(entry.pointerEvent) - : null, - items: entry.items - .map((item) => clonePointerItem(item)) - .filter(Boolean), - }; - } - } - - if (!this.pool) { - const fallbackEntry = this.createWatchHistoryEntry( - null, - [], - Date.now(), - this.watchHistoryCache.get(actor) || null - ); - 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 filter = { - kinds: [WATCH_HISTORY_KIND], - authors: [actor], - "#d": [WATCH_HISTORY_LIST_IDENTIFIER], - limit: fetchLimit, + return { + ok: success, + event: signedEvent, + results: publishResults, + acceptedRelays, }; + } - 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); - } - } - fetchedHeadEvents = Array.from(dedupe.values()); - } - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to fetch watch history list:", error); - } + async recordVideoView(videoPointer, options = {}) { + const pointer = normalizePointerInput(videoPointer); + if (!pointer) { + return { ok: false, error: "invalid-pointer" }; } - 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 view = await this.publishViewEvent(pointer, options); - const chunkIdentifiers = new Set(); - const snapshotIds = new Set(); - - for (const event of fetchedHeadEvents) { - if (!event || typeof event !== "object") { - continue; - } - - const tags = Array.isArray(event.tags) ? event.tags : []; - 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]); - } else if (tag[0] === "a") { - const pointer = normalizePointerTag(tag); - if (pointer?.type === "a") { - const [kindStr, pubkey, identifier] = pointer.value.split(":"); - const kind = Number.parseInt(kindStr, 10); - if ( - Number.isFinite(kind) && - kind === WATCH_HISTORY_KIND && - typeof pubkey === "string" && - pubkey === actor && - typeof identifier === "string" && - identifier && - identifier !== WATCH_HISTORY_LIST_IDENTIFIER - ) { - chunkIdentifiers.add(identifier); - } - } - } - } - } - - const chunkFilters = []; - const chunkFetchLimit = Math.max( - fetchLimit, - chunkIdentifiers.size ? chunkIdentifiers.size + 1 : 0, - snapshotIds.size ? snapshotIds.size * 2 : 0 - ); - - if (chunkIdentifiers.size) { - chunkFilters.push({ - kinds: [WATCH_HISTORY_KIND], - authors: [actor], - "#d": Array.from(chunkIdentifiers), - limit: chunkFetchLimit, - }); - } - - if (snapshotIds.size) { - chunkFilters.push({ - kinds: [WATCH_HISTORY_KIND], - authors: [actor], - "#snapshot": Array.from(snapshotIds), - limit: chunkFetchLimit, - }); - } - - let fetchedChunkEvents = []; - if (chunkFilters.length) { + if (view?.ok && view.event) { try { - const events = await this.pool.list(relayList, chunkFilters); - if (Array.isArray(events)) { - fetchedChunkEvents = events; + const ingest = await loadIngestLocalViewEvent(); + if (typeof ingest === "function") { + ingest({ event: view.event, pointer }); } } catch (error) { if (isDevMode) { console.warn( - "[nostr] Failed to fetch watch history chunks:", + "[nostr] Failed to ingest optimistic view event:", 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; - - for (const tag of tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - 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, - }; - - bucket.chunks.set(chunkIndex, { - event, - payload: { - ...payload, - snapshot: snapshotId, - chunkIndex, - totalChunks, - }, - created_at: typeof event.created_at === "number" ? event.created_at : 0, - }); - 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); - 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 = 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), - }; - - if (!fallbackSnapshot) { - fallbackSnapshot = snapshot; - } - - if (snapshot.chunkCount >= snapshot.expectedChunks) { - selectedSnapshot = snapshot; - break; - } - - if (!selectedSnapshot) { - selectedSnapshot = snapshot; - } - } - - 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 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; - } - } - - let items = remoteItems; - let usedFallback = false; - const remoteVersion = effectiveSnapshot?.version ?? 0; - - if (!effectiveSnapshot || (remoteVersion === 0 && !remoteItems.length)) { - if (previousEntry?.items?.length) { - items = previousEntry.items; - usedFallback = true; - } else if (storedItems.length) { - items = storedItems; - usedFallback = true; - } - } - - const entry = this.createWatchHistoryEntry( - pointerForEntry, - items, - Date.now(), - previousEntry - ); - - if (!(entry.chunkEvents instanceof Map)) { - entry.chunkEvents = new Map(); - } else { - entry.chunkEvents.clear(); - } - - const chunkPointerItemsForEntry = []; - let headPointerForEntry = null; - const chunkPointerKeys = new Set(); - - 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) { - continue; - } - const clonedChunkEvent = cloneEventForCache(chunk.event); - if (clonedChunkEvent) { - entry.chunkEvents.set(key, clonedChunkEvent); - } - chunkPointerKeys.add(key); - if (chunk.payload?.chunkIndex === 0) { - headPointerForEntry = pointer; - } else { - chunkPointerItemsForEntry.push(pointer); - } - } - } - } - - entry.chunkPointers = { - head: headPointerForEntry, - chunks: chunkPointerItemsForEntry, - }; - - const headPointerKey = headPointerForEntry ? pointerKey(headPointerForEntry) : ""; - if (headPointerKey) { - chunkPointerKeys.add(headPointerKey); - if (entry.pointerEvent) { - entry.chunkEvents.set(headPointerKey, cloneEventForCache(entry.pointerEvent)); - } - } - - for (const key of Array.from(entry.resolved.keys())) { - if ( - !key || - !key.startsWith(`a:${WATCH_HISTORY_KIND}:`) - ) { - 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) || kind !== WATCH_HISTORY_KIND) { - return "none"; - } - const identifier = identifierParts.join(":"); - const normalizedIdentifier = - typeof identifier === "string" ? identifier.trim() : ""; - if (!normalizedIdentifier) { - return "none"; - } - const normalizedIdentifierLower = normalizedIdentifier.toLowerCase(); - if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) { - return "head"; - } - if (!normalizedIdentifierLower.startsWith("watch-history:")) { - return "none"; - } - const normalizedOwner = - typeof ownerPubkey === "string" && ownerPubkey.trim() - ? ownerPubkey.trim().toLowerCase() - : ""; - const normalizedPubkey = - typeof pubkeyRaw === "string" && pubkeyRaw.trim() - ? pubkeyRaw.trim().toLowerCase() - : ""; - if (!normalizedOwner || !normalizedPubkey || normalizedOwner === normalizedPubkey) { - return "chunk"; - } - return "none"; - }; - - const addPointerToMap = (pointer, map) => { - const normalized = clonePointerItem(pointer); - if (!normalized) { - return null; - } - const key = pointerKey(normalized); - if (!key) { - return null; - } - map.set(key, normalized); - return normalized; - }; - - const headPointerMap = new Map(); - const chunkPointerMap = new Map(); - const videoCandidates = []; - - if (entry.chunkPointers?.head) { - addPointerToMap(entry.chunkPointers.head, headPointerMap); - } - if (Array.isArray(entry.chunkPointers?.chunks)) { - for (const chunkPointer of entry.chunkPointers.chunks) { - addPointerToMap(chunkPointer, chunkPointerMap); - } - } - - for (const item of entry.items) { - const pointer = clonePointerItem(item); - if (!pointer) { - continue; - } - const pointerType = classifyWatchHistoryPointer(pointer, actor); - if (pointerType === "head") { - addPointerToMap(pointer, headPointerMap); - continue; - } - if (pointerType === "chunk") { - addPointerToMap(pointer, chunkPointerMap); - continue; - } - videoCandidates.push(pointer); - } - - const pointerEventClone = entry.pointerEvent - ? cloneEventForCache(entry.pointerEvent) - : null; - for (const pointer of headPointerMap.values()) { - const key = pointerKey(pointer); - if (!key) { - continue; - } - if (pointerEventClone) { - entry.resolved.set(key, pointerEventClone); - if (entry.chunkEvents instanceof Map) { - entry.chunkEvents.set(key, pointerEventClone); - } - } else if (!entry.resolved.has(key)) { - entry.resolved.set(key, null); - } - entry.resolving.delete(key); - } - - const available = videoCandidates.filter((item) => { - const key = pointerKey(item); - if (!key) { - return false; - } - if (entry.delivered.has(key)) { - return false; - } - if (entry.resolving.has(key)) { - return false; - } - if (entry.resolvedVideos.has(key)) { - const cachedVideo = entry.resolvedVideos.get(key); - if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { - return false; - } - } - return true; - }); - - const chunkAvailable = []; - for (const [key, pointer] of chunkPointerMap.entries()) { - if (!key) { - continue; - } - if (entry.resolved.has(key)) { - continue; - } - if (entry.resolving.has(key)) { - continue; - } - const cachedChunk = entry.chunkEvents?.get?.(key); - if (cachedChunk) { - const cloned = cloneEventForCache(cachedChunk); - if (cloned) { - entry.chunkEvents.set(key, cloned); - entry.resolved.set(key, cloned); - } else { - entry.resolved.set(key, cachedChunk); - } - continue; - } - chunkAvailable.push(pointer); - } - - const videoBatch = available.slice(0, effectiveBatchSize); - const batch = [...videoBatch, ...chunkAvailable]; - - const relaySet = new Set( - Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS - ); - const filters = []; - - if (batch.length) { - batch.forEach((item) => { - const key = pointerKey(item); - if (key) { - entry.resolving.add(key); - } - }); - - for (const pointer of batch) { - if (pointer.relay) { - relaySet.add(pointer.relay); - } - if (pointer.type === "e") { - filters.push({ ids: [pointer.value] }); - } else if (pointer.type === "a") { - const [kindStr, pubkey, identifier] = pointer.value.split(":"); - const kind = Number(kindStr); - if (Number.isFinite(kind) && pubkey) { - const filter = { kinds: [kind], authors: [pubkey] }; - if (identifier) { - filter["#d"] = [identifier]; - } - filters.push(filter); - } - } - } - } - - let events = []; - if (filters.length) { - try { - events = await this.pool.list(Array.from(relaySet), filters); - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to resolve watch history batch:", error); - } - } - } else if (batch.length) { - batch.forEach((item) => entry.resolving.delete(pointerKey(item))); - } - - const pointerMatches = new Map(); - - for (const evt of events || []) { - if (!evt || typeof evt !== "object") { - continue; - } - if (typeof evt.id === "string" && evt.id) { - pointerMatches.set( - pointerKey({ type: "e", value: evt.id }), - evt - ); - } - const addressPointer = eventToAddressPointer(evt); - if (addressPointer) { - pointerMatches.set( - pointerKey({ type: "a", value: addressPointer }), - evt - ); - } - } - - for (const pointer of batch) { - const key = pointerKey(pointer); - if (!key) { - continue; - } - const pointerType = classifyWatchHistoryPointer(pointer, actor); - const event = pointerMatches.get(key); - if (pointerType === "chunk" || pointerType === "head") { - if (event) { - const cloned = cloneEventForCache(event) || event; - if (!entry.chunkEvents) { - entry.chunkEvents = new Map(); - } - entry.chunkEvents.set(key, cloned); - entry.resolved.set(key, cloned); - } - entry.resolving.delete(key); - continue; - } - - if (event) { - try { - const video = convertEventToVideo(event); - if (!video.invalid) { - this.allEvents.set(event.id, video); - if (!video.deleted) { - const activeKey = getActiveKey(video); - const existing = this.activeMap.get(activeKey); - if (!existing || video.created_at > existing.created_at) { - this.activeMap.set(activeKey, video); - } - } - entry.resolvedVideos.set(key, video); - } - } catch (err) { - if (isDevMode) { - console.warn("[nostr] Failed to convert watch history event:", err); - } - } - } - entry.resolving.delete(key); - } - - const deliverableVideos = []; - for (const pointer of videoCandidates) { - if (deliverableVideos.length >= effectiveBatchSize) { - break; - } - const key = pointerKey(pointer); - if (!key || entry.delivered.has(key)) { - continue; - } - const cachedVideo = entry.resolvedVideos.get(key); - if (cachedVideo && typeof cachedVideo === "object" && cachedVideo.id) { - const pointerClone = clonePointerItem(pointer); - const watchedAt = Number.isFinite(pointerClone?.watchedAt) - ? pointerClone.watchedAt - : 0; - const videoWithHistory = { - ...cachedVideo, - watchHistory: { - key, - watchedAt, - pointer: - pointerClone || { - type: pointer.type, - value: pointer.value, - relay: pointer.relay || null, - watchedAt, - }, - }, - }; - deliverableVideos.push(videoWithHistory); - entry.delivered.add(key); - } - } - - return deliverableVideos; - } - - async recordVideoView(videoPointer, options = {}) { - const pointer = normalizePointerInput(videoPointer); - if (!pointer) { - return { - ok: false, - error: "invalid-pointer", - view: { ok: false, error: "invalid-pointer" }, - history: { ok: false, error: "invalid-pointer" }, - }; - } - - const view = await this.publishViewEvent(pointer, options); - const history = await this.updateWatchHistoryList(pointer); - - return { - ok: view.ok && history.ok, - view, - history, - }; + return view; } /** @@ -3269,9 +3926,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."); } @@ -3442,16 +4110,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); @@ -3462,17 +4126,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 = @@ -3496,13 +4197,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); @@ -3514,23 +4214,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( @@ -3689,21 +4420,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); @@ -3794,16 +4556,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; } @@ -4275,6 +5073,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."); @@ -4342,9 +5144,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); @@ -4366,7 +5167,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 = @@ -4376,8 +5177,29 @@ class NostrClient { ? this.relays : RELAY_URLS; - const perRelay = 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, @@ -4385,23 +5207,72 @@ 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 total = perRelay.reduce((sum, entry) => { - if (!entry || !entry.ok) { - return sum; + const resultsByUrl = new Map(); + for (const entry of [...precomputedEntries, ...activeResults]) { + if (entry && typeof entry.url === "string") { + resultsByUrl.set(entry.url, entry); } - const value = Number(entry.count); - return Number.isFinite(value) && value > 0 ? sum + value : sum; - }, 0); + } - return { total, perRelay }; + const perRelayResults = normalizedRelayList.map((url) => { + if (resultsByUrl.has(url)) { + return resultsByUrl.get(url); + } + return { url, ok: false }; + }); + + let bestEstimate = null; + const perRelay = perRelayResults.map((entry) => { + if (!entry || !entry.ok) { + return entry; + } + + const numericValue = Number(entry.count); + const normalizedCount = + Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0; + + const normalizedEntry = { + ...entry, + count: normalizedCount, + }; + + if (!Number.isFinite(numericValue) || numericValue < 0) { + normalizedEntry.rawCount = entry.count; + } + + if ( + !bestEstimate || + normalizedCount > bestEstimate.count || + (bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame) + ) { + bestEstimate = { + relay: normalizedEntry.url, + count: normalizedCount, + frame: normalizedEntry.frame, + }; + } + + return normalizedEntry; + }); + + const total = bestEstimate ? bestEstimate.count : 0; + + return { + total, + best: bestEstimate, + perRelay, + }; } /** @@ -4539,6 +5410,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) => @@ -4549,3 +5528,26 @@ 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); +}; + +export { normalizePointerInput, pointerKey, chunkWatchHistoryPayloadItems, normalizeActorKey }; diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js new file mode 100644 index 00000000..f07eae8d --- /dev/null +++ b/js/nostrEventSchemas.js @@ -0,0 +1,780 @@ +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", + RELAY_LIST: "relayList", + VIEW_EVENT: "viewEvent", + WATCH_HISTORY_INDEX: "watchHistoryIndex", + 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 = []; + +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, + 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.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", + kind: WATCH_HISTORY_KIND, + topicTag: { name: "t", value: "view" }, + 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_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", + }, + 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 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, + pointerValue, + pointerTag, + pointerTags = [], + 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; + if (pointerValue && pointerTagName) { + tags.push([pointerTagName, pointerValue]); + } + const normalizedPointerTags = []; + if (Array.isArray(pointerTag) && pointerTag.length >= 2) { + 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) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + if (dedupeTag && schema?.identifierTag?.name) { + const identifierName = schema.identifierTag.name; + 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); + + const resolvedContent = ensureValidUtf8Content(content); + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: resolvedContent, + }; +} + +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, + 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/nostrPublish.js b/js/nostrPublish.js new file mode 100644 index 00000000..5faa25f3 --- /dev/null +++ b/js/nostrPublish.js @@ -0,0 +1,202 @@ +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("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; + } + } + + 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 new file mode 100644 index 00000000..4c6fff37 --- /dev/null +++ b/js/relayManager.js @@ -0,0 +1,565 @@ +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"]; + +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 publishEventToRelays( + nostrClient.pool, + targets, + signedEvent + ); + + 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 + ); + } + ); + } + 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( + `[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() { + return this.lastLoadSource; + } +} + +export const relayManager = new RelayPreferencesManager(); + +export function normalizeRelayUrlForDisplay(value) { + return normalizeRelayUrl(value); +} 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 95392d7f..8b4b14f8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -3,8 +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) { @@ -58,7 +111,7 @@ class SubscriptionsManager { const filter = { kinds: [30002], authors: [userPubkey], - "#d": ["subscriptions"], + "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1, }; @@ -168,33 +221,68 @@ 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, - }; + }); + 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 + ); } /** @@ -224,6 +312,8 @@ class SubscriptionsManager { return; } + container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…"); + // Gather all videos const videos = await this.fetchSubscribedVideos(channelHexes); this.renderSameGridStyle(videos, containerId); @@ -604,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/urlHealthObserver.js b/js/urlHealthObserver.js index 05e16920..c3746190 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -1,53 +1,23 @@ -const containerState = new WeakMap(); +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; - } -} - -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; } @@ -71,7 +41,7 @@ function processEntries(entries, state) { return; } - const url = decodeUrl(encodedUrl); + const url = safeDecodeURIComponent(encodedUrl); if (!url) { return; } @@ -81,44 +51,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); } diff --git a/js/userBlocks.js b/js/userBlocks.js index 2845d203..30228d81 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,7 +1,10 @@ // js/userBlocks.js import { nostrClient } from "./nostr.js"; - -const BLOCK_LIST_IDENTIFIER = "user-blocks"; +import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; function normalizeHex(pubkey) { if (typeof pubkey !== "string") { @@ -250,26 +253,54 @@ 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); - 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/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/viewCounter.js b/js/viewCounter.js new file mode 100644 index 00000000..72ab3350 --- /dev/null +++ b/js/viewCounter.js @@ -0,0 +1,643 @@ +/** + * @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; + } + } + 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); + const shouldUpdate = + !countResult?.fallback || bestCount >= state.total || state.total === 0; + if (shouldUpdate && state.total !== bestCount) { + state.total = bestCount; + 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); +} diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js new file mode 100644 index 00000000..94002c05 --- /dev/null +++ b/js/watchHistoryService.js @@ -0,0 +1,1404 @@ +// 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, + getWatchHistoryV2Enabled, +} 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 METADATA_STORAGE_KEY = "bitvid:watch-history:metadata-cache:v1"; +const METADATA_STORAGE_VERSION = 1; +const METADATA_PREFERENCE_KEY = "bitvid:watch-history:metadata:store-locally"; + +const state = { + restored: false, + queues: new Map(), + inflightSnapshots: new Map(), + fingerprintCache: new Map(), + listeners: new Map(), + metadata: { + restored: false, + cache: new Map(), + preference: null, + }, +}; + +function resolveFlagEnabled() { + try { + if (typeof getWatchHistoryV2Enabled === "function") { + return getWatchHistoryV2Enabled() === true; + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to read FEATURE_WATCH_HISTORY_V2 flag:", + error, + ); + } + } + return FEATURE_WATCH_HISTORY_V2 === true; +} + +function getLoggedInActorKey() { + const direct = normalizeActorKey(nostrClient?.pubkey); + if (direct) { + return direct; + } + + if (typeof window !== "undefined") { + const appCandidate = + (window.bitvid && window.bitvid.app) || window.app || null; + if (appCandidate && typeof appCandidate === "object") { + if (typeof appCandidate.normalizeHexPubkey === "function") { + try { + const normalized = appCandidate.normalizeHexPubkey( + appCandidate.pubkey + ); + if (normalized) { + return normalizeActorKey(normalized); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to normalize app login pubkey:", + error + ); + } + } + } + + if (typeof appCandidate.pubkey === "string" && appCandidate.pubkey) { + const fallback = normalizeActorKey(appCandidate.pubkey); + if (fallback) { + return fallback; + } + } + } + } + + return ""; +} + +function getSessionActorKey() { + const logged = getLoggedInActorKey(); + if (logged) { + return ""; + } + return normalizeActorKey(nostrClient?.sessionActor?.pubkey); +} + +function resolveEffectiveActorKey(actorInput) { + const supplied = + typeof actorInput === "string" && actorInput.trim() + ? normalizeActorKey(actorInput) + : ""; + if (supplied) { + return supplied; + } + const logged = getLoggedInActorKey(); + if (logged) { + return logged; + } + const session = getSessionActorKey(); + if (session) { + return session; + } + return ""; +} + +function isFeatureEnabled(actorInput) { + const baseEnabled = resolveFlagEnabled(); + const actorKey = resolveEffectiveActorKey(actorInput); + const logged = getLoggedInActorKey(); + const session = getSessionActorKey(); + + if (actorKey && logged && actorKey === logged) { + return true; + } + + if (actorKey && session && actorKey === session) { + return false; + } + + if (!actorKey) { + if (logged) { + return true; + } + if (session) { + return false; + } + } + + return baseEnabled; +} + +function isLocalOnly(actorInput) { + const actorKey = resolveEffectiveActorKey(actorInput); + const session = getSessionActorKey(); + if (!session) { + return false; + } + if (!actorKey) { + return true; + } + return actorKey === session; +} + +function supportsLocalHistory(actorInput) { + return isLocalOnly(actorInput); +} + +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 getLocalStorage() { + try { + if (typeof window !== "undefined" && window.localStorage) { + return window.localStorage; + } + } catch (error) { + if (isDevMode) { + console.warn("[watchHistoryService] localStorage unavailable:", error); + } + } + return null; +} + +function ensureMetadataPreference() { + if (typeof state.metadata.preference === "boolean") { + return state.metadata.preference; + } + const storage = getLocalStorage(); + if (!storage) { + state.metadata.preference = true; + return state.metadata.preference; + } + try { + const stored = storage.getItem(METADATA_PREFERENCE_KEY); + if (stored === null) { + state.metadata.preference = true; + } else { + state.metadata.preference = stored === "true"; + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to read metadata preference:", + error, + ); + } + state.metadata.preference = true; + } + return state.metadata.preference; +} + +function persistMetadataPreference(value) { + const storage = getLocalStorage(); + if (!storage) { + return; + } + try { + if (value === true) { + storage.setItem(METADATA_PREFERENCE_KEY, "true"); + } else { + storage.setItem(METADATA_PREFERENCE_KEY, "false"); + } + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to persist metadata preference:", + error, + ); + } + } +} + +function sanitizeVideoForStorage(video) { + if (!video || typeof video !== "object") { + return null; + } + const createdAt = Number.isFinite(video.created_at) + ? Math.floor(Number(video.created_at)) + : null; + return { + id: typeof video.id === "string" ? video.id : "", + title: typeof video.title === "string" ? video.title : "", + thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "", + url: typeof video.url === "string" ? video.url : "", + magnet: typeof video.magnet === "string" ? video.magnet : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + created_at: createdAt, + infoHash: typeof video.infoHash === "string" ? video.infoHash : "", + mode: typeof video.mode === "string" ? video.mode : "", + isPrivate: video?.isPrivate === true, + description: + typeof video.description === "string" ? video.description : "", + }; +} + +function sanitizeProfileForStorage(profile) { + if (!profile || typeof profile !== "object") { + return null; + } + return { + pubkey: typeof profile.pubkey === "string" ? profile.pubkey : "", + name: typeof profile.name === "string" ? profile.name : "", + display_name: + typeof profile.display_name === "string" ? profile.display_name : "", + picture: typeof profile.picture === "string" ? profile.picture : "", + nip05: typeof profile.nip05 === "string" ? profile.nip05 : "", + }; +} + +function restoreMetadataCache() { + if (state.metadata.restored) { + return; + } + state.metadata.restored = true; + + const storage = getLocalStorage(); + if (!storage) { + return; + } + + let raw = null; + try { + raw = storage.getItem(METADATA_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to read metadata 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 metadata cache:", + error, + ); + } + try { + storage.removeItem(METADATA_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear corrupt metadata cache:", + cleanupError, + ); + } + } + return; + } + + if (!parsed || parsed.version !== METADATA_STORAGE_VERSION) { + try { + storage.removeItem(METADATA_STORAGE_KEY); + } catch (cleanupError) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear outdated metadata cache:", + cleanupError, + ); + } + } + return; + } + + state.metadata.cache.clear(); + const entries = parsed.entries && typeof parsed.entries === "object" + ? parsed.entries + : {}; + for (const [key, entry] of Object.entries(entries)) { + if (typeof key !== "string" || !key) { + continue; + } + const sanitizedVideo = sanitizeVideoForStorage(entry?.video); + const sanitizedProfile = sanitizeProfileForStorage(entry?.profile); + state.metadata.cache.set(key, { + video: sanitizedVideo, + profile: sanitizedProfile, + storedAt: Number.isFinite(entry?.storedAt) ? entry.storedAt : Date.now(), + }); + } +} + +function persistMetadataCache() { + const storage = getLocalStorage(); + if (!storage) { + return; + } + const shouldStore = shouldStoreMetadataLocally(); + if (!shouldStore || !state.metadata.cache.size) { + try { + storage.removeItem(METADATA_STORAGE_KEY); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to clear metadata cache:", + error, + ); + } + } + return; + } + + const payload = { version: METADATA_STORAGE_VERSION, entries: {} }; + for (const [key, entry] of state.metadata.cache.entries()) { + payload.entries[key] = { + video: sanitizeVideoForStorage(entry?.video) || null, + profile: sanitizeProfileForStorage(entry?.profile) || null, + storedAt: Number.isFinite(entry?.storedAt) ? entry.storedAt : Date.now(), + }; + } + try { + storage.setItem(METADATA_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + if (isDevMode) { + console.warn( + "[watchHistoryService] Failed to persist metadata cache:", + error, + ); + } + } +} + +function shouldStoreMetadataLocally() { + return ensureMetadataPreference() === true; +} + +function setMetadataPreference(enabled) { + const normalized = enabled === false ? false : true; + const previous = ensureMetadataPreference(); + state.metadata.preference = normalized; + persistMetadataPreference(normalized); + if (!normalized) { + state.metadata.cache.clear(); + persistMetadataCache(); + } else { + persistMetadataCache(); + } + if (previous !== normalized) { + emit("metadata-preference", { enabled: normalized }); + } +} + +function getMetadataPreference() { + return ensureMetadataPreference(); +} + +function getLocalMetadata(pointerKeyValue) { + restoreMetadataCache(); + const key = typeof pointerKeyValue === "string" ? pointerKeyValue : ""; + if (!key) { + return null; + } + const stored = state.metadata.cache.get(key); + if (!stored) { + return null; + } + return { + video: stored.video ? { ...stored.video } : null, + profile: stored.profile ? { ...stored.profile } : null, + storedAt: stored.storedAt, + }; +} + +function setLocalMetadata(pointerKeyValue, metadata) { + if (!shouldStoreMetadataLocally()) { + return; + } + const key = typeof pointerKeyValue === "string" ? pointerKeyValue : ""; + if (!key) { + return; + } + restoreMetadataCache(); + if (!metadata || typeof metadata !== "object") { + state.metadata.cache.delete(key); + persistMetadataCache(); + return; + } + const video = sanitizeVideoForStorage(metadata.video); + const profile = sanitizeProfileForStorage(metadata.profile); + state.metadata.cache.set(key, { + video, + profile, + storedAt: Date.now(), + }); + persistMetadataCache(); +} + +function removeLocalMetadata(pointerKeyValue) { + const key = typeof pointerKeyValue === "string" ? pointerKeyValue : ""; + if (!key) { + return; + } + restoreMetadataCache(); + if (state.metadata.cache.delete(key)) { + persistMetadataCache(); + } +} + +function clearLocalMetadata() { + restoreMetadataCache(); + if (!state.metadata.cache.size) { + return; + } + state.metadata.cache.clear(); + persistMetadataCache(); +} + +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(actorKey)) { + 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) { + if (!isFeatureEnabled(actorKey)) { + return; + } + 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?.snapshotId) { + queue.pendingSnapshotId = publishResult.snapshotId; + } + if (publishResult?.ok) { + queue.pendingSnapshotId = null; + queue.republishScheduled = false; + queue.items.clear(); + queue.throttle.clear(); + 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; + }, + { + onSchedule: ({ delay }) => { + emit("republish-scheduled", { + actor: actorKey, + snapshotId, + delayMs: Number.isFinite(delay) ? delay : null, + }); + }, + } + ); +} + +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); + + console.info( + "[watchHistoryService] Video view recorded. Preparing watch list update.", + { + pointer: pointerInput, + createdAt, + hasMetadata: !!metadata, + } + ); + + const pointer = normalizePointerInput(pointerInput); + if (!pointer) { + console.warn( + "[watchHistoryService] Skipping watch list update because pointer normalization failed.", + { pointerInput } + ); + 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) { + console.warn( + "[watchHistoryService] Unable to resolve actor for watch list update.", + { actorCandidate } + ); + return viewResult; + } + + const queue = ensureQueue(actorKey); + if (!queue) { + console.warn( + "[watchHistoryService] Failed to resolve queue for actor.", + { actorKey } + ); + return viewResult; + } + + const remoteEnabled = isFeatureEnabled(actorKey); + + const normalizedPointer = normalizePointerInput(pointer); + if (!normalizedPointer) { + console.warn( + "[watchHistoryService] Pointer normalization failed during queue preparation.", + { pointerInput } + ); + return viewResult; + } + + normalizedPointer.watchedAt = resolveWatchedAt( + createdAt, + viewResult?.event?.created_at + ); + + const key = pointerKey(normalizedPointer); + console.info( + "[watchHistoryService] Watch list process triggered by video view.", + { + actor: actorKey, + pointerKey: key, + watchedAt: normalizedPointer.watchedAt, + sessionEvent: normalizedPointer.session === true, + remoteSyncEnabled: remoteEnabled, + } + ); + + const normalizedLogged = normalizeActorKey(nostrClient?.pubkey); + const normalizedEventActor = normalizeActorKey( + viewResult?.event?.pubkey || actorCandidate + ); + if (normalizedEventActor && normalizedEventActor !== normalizedLogged) { + normalizedPointer.session = true; + } + + if (!key) { + console.warn( + "[watchHistoryService] Pointer key generation failed. Skipping queue update.", + { pointer: normalizedPointer } + ); + 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); + console.info( + "[watchHistoryService] Updated existing watch list entry.", + { + actor: actorKey, + pointerKey: key, + watchedAt: existing.pointer.watchedAt, + } + ); + 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); + + console.info( + "[watchHistoryService] Added pointer to pending watch list snapshot queue.", + { + actor: actorKey, + pointerKey: key, + queueSize: queue.items.size, + } + ); + + if (!remoteEnabled) { + console.info( + "[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.", + { + actor: actorKey, + pointerKey: key, + watchedAt: normalizedPointer.watchedAt, + } + ); + } + + return viewResult; +} + +async function snapshot(items, options = {}) { + const reason = typeof options.reason === "string" ? options.reason : "manual"; + const actorKey = resolveActorKey(options.actor); + if (!actorKey) { + return { ok: false, error: "missing-actor" }; + } + + if (!isFeatureEnabled(actorKey)) { + console.info( + "[watchHistoryService] Snapshot skipped because watch history sync is unavailable for this actor.", + { + actor: actorKey, + requestedItems: Array.isArray(items) ? items.length : 0, + reason, + } + ); + return { ok: true, skipped: true, reason: "feature-disabled" }; + } + + 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); + + console.info( + "[watchHistoryService] Initiating watch list snapshot publish.", + { + actor: actorKey, + reason, + itemCount: payloadItems.length, + queued: queue?.items?.size ?? 0, + } + ); + + const run = (async () => { + emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); + const publishResult = await nostrClient.publishWatchHistorySnapshot( + payloadItems, + { + actorPubkey: actorKey, + source: reason, + } + ); + + console.info( + "[watchHistoryService] Watch list snapshot publish completed.", + { + actor: actorKey, + reason, + success: !!publishResult?.ok, + snapshotId: publishResult?.snapshotId || null, + retryable: !!publishResult?.retryable, + } + ); + + 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 }); + console.info( + "[watchHistoryService] Watch list snapshot processed and fingerprint cache updated.", + { + actor: actorKey, + reason, + fingerprintUpdated: true, + } + ); + 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) { + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return []; + } + + if (!isFeatureEnabled(actorKey)) { + if (!state.restored) { + restoreQueueState(); + } + const items = collectQueueItems(actorKey); + console.info( + "[watchHistoryService] loadLatest returning local-only watch history queue.", + { + actor: actorKey, + itemCount: items.length, + } + ); + return items; + } + + const now = Date.now(); + const cacheEntry = state.fingerprintCache.get(actorKey); + console.info( + "[watchHistoryService] loadLatest invoked.", + { + actor: actorKey, + cacheHasItems: Array.isArray(cacheEntry?.items), + cacheExpiresAt: cacheEntry?.expiresAt || null, + } + ); + if ( + cacheEntry?.items && + cacheEntry.expiresAt && + cacheEntry.expiresAt > now && + Array.isArray(cacheEntry.items) + ) { + console.info( + "[watchHistoryService] Returning cached watch list items.", + { + actor: actorKey, + itemCount: cacheEntry.items.length, + } + ); + return cacheEntry.items; + } + + if (cacheEntry?.promise) { + console.info( + "[watchHistoryService] Awaiting in-flight watch list refresh.", + { + actor: actorKey, + } + ); + 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; + } + }); + + console.info( + "[watchHistoryService] Triggered nostr watch list refresh.", + { + actor: actorKey, + } + ); + + state.fingerprintCache.set(actorKey, { + ...(cacheEntry || {}), + promise, + }); + + return promise; +} + +async function getFingerprint(actorInput) { + const actorKey = resolveActorKey(actorInput); + if (!actorKey) { + return ""; + } + if (!isFeatureEnabled(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; +} + +function getSettings() { + const preference = getMetadataPreference(); + return { + metadata: { + preference, + storeLocally: preference === true, + cacheSize: state.metadata.cache.size, + }, + }; +} + +const watchHistoryService = { + isEnabled: isFeatureEnabled, + supportsLocalHistory, + isLocalOnly, + publishView, + snapshot, + loadLatest, + getFingerprint, + resetProgress, + getQueuedPointers, + getAllQueues, + getMetadataPreference, + setMetadataPreference, + shouldStoreMetadata: shouldStoreMetadataLocally, + getLocalMetadata, + setLocalMetadata, + removeLocalMetadata, + clearLocalMetadata, + getSettings, + subscribe, +}; + +if (typeof window !== "undefined") { + window.bitvid = window.bitvid || {}; + window.bitvid.watchHistory = watchHistoryService; +} + +export { watchHistoryService }; +export default watchHistoryService; 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/js/webtorrent.js b/js/webtorrent.js index 8e8add87..3b1d8cd1 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -1,22 +1,10 @@ //js/webtorrent.js 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://tracker.btorrent.xyz", - "wss://tracker.openwebtorrent.com", -]); - -function decodeComponentSafe(value) { - if (typeof value !== "string") { - return ""; - } - try { - return decodeURIComponent(value); - } catch (err) { - return value; - } -} +const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); function normalizeTrackerList(trackers) { const normalized = []; @@ -75,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/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" + ); +}); diff --git a/tests/nostr-count-fallback.test.mjs b/tests/nostr-count-fallback.test.mjs new file mode 100644 index 00000000..f3490498 --- /dev/null +++ b/tests/nostr-count-fallback.test.mjs @@ -0,0 +1,215 @@ +// 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" + ); + } +} + +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."); 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; + } +} 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" + ); +}); diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs new file mode 100644 index 00000000..ac78e4a4 --- /dev/null +++ b/tests/view-counter.test.mjs @@ -0,0 +1,708 @@ +// Run with: node tests/view-counter.test.mjs + +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, +} = 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" }; + +// 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 = {}; +} + +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], + ["d", "ignore-dedupe"], + ], + "view event should include topic, pointer, and dedupe tags when provided" +); + +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() { + const storedEvents = new Map(); + const customCountResults = 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) { + 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 = {}) => { + metrics.list += 1; + 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) => { + metrics.count += 1; + const key = pointerKeyFromInput(pointer); + 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, perRelay: [], best: null }; + }; + + 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 ( + 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 { + customCountResults.delete(key); + } + }; + + const reset = () => { + storedEvents.clear(); + customCountResults.clear(); + subscribers.clear(); + resetMetrics(); + }; + + const subscriptionCount = (key) => { + const handlers = subscribers.get(key); + return handlers ? handlers.size : 0; + }; + + const getCallCounts = () => ({ list: metrics.list, count: metrics.count }); + + return { + listVideoViewEvents, + countVideoViewEvents, + subscribeVideoViewEvents, + emit, + setEvents, + setCountTotal, + reset, + pointerKeyFromInput, + subscriptionCount, + resetMetrics, + getCallCounts, + }; +} + +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(); + harness.resetMetrics(); + + 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(); + harness.resetMetrics(); + + 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; + + 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 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(); + harness.resetMetrics(); + + 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(); + harness.resetMetrics(); + + 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" + ); +} + +async function testRecordVideoViewEmitsJsonPayload() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const pointer = { + type: "a", + value: `${WATCH_HISTORY_KIND}: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 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 = ""; + + + 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.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; + 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(); + 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(); +await testUnsubscribeStopsCallbacks(); +await testRelayCountAggregationUsesBestEstimate(); +await testRecordVideoViewEmitsJsonPayload(); + +console.log("View counter tests completed successfully."); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index a7e25b40..b9fae06a 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1,746 +1,1230 @@ +// Run with: node tests/watch-history.test.mjs + import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; +const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } = + await import("../js/config.js"); const { - WATCH_HISTORY_LIST_IDENTIFIER, - WATCH_HISTORY_KIND, -} = await import("../js/config.js"); + getWatchHistoryV2Enabled, + setWatchHistoryV2Enabled, +} = await import("../js/constants.js"); +const { + nostrClient, + chunkWatchHistoryPayloadItems, +} = await import("../js/nostr.js"); +const { watchHistoryService } = await import("../js/watchHistoryService.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; } -if (!globalThis.window.NostrTools) { - globalThis.window.NostrTools = {}; +if (!window.crypto || !window.crypto.subtle) { + const { webcrypto } = await import("node:crypto"); + window.crypto = webcrypto; } -globalThis.window.NostrTools.getEventHash = (event) => { - const payload = `${event.kind}:${event.pubkey}:${event.created_at}`; - return `hash-${Buffer.from(payload).toString("hex")}`; +const originalFlag = getWatchHistoryV2Enabled(); +setWatchHistoryV2Enabled(true); + +const originalWindowNostr = window.nostr; +const originalNostrTools = window.NostrTools || {}; +const originalPool = nostrClient.pool; +const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; +const originalReadRelays = Array.isArray(nostrClient.readRelays) + ? [...nostrClient.readRelays] + : nostrClient.readRelays; +const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; +const originalEnsureSessionActor = nostrClient.ensureSessionActor; +const originalSessionActor = nostrClient.sessionActor; +const originalPubkey = nostrClient.pubkey; +const originalWatchHistoryLastCreatedAt = nostrClient.watchHistoryLastCreatedAt; +const originalRecordVideoView = nostrClient.recordVideoView; +const originalWatchHistoryCache = nostrClient.watchHistoryCache; +const originalWatchHistoryStorage = nostrClient.watchHistoryStorage; +const originalScheduleWatchHistoryRepublish = + nostrClient.scheduleWatchHistoryRepublish; +const originalResolveWatchHistory = nostrClient.resolveWatchHistory; +const originalGetWatchHistoryFingerprint = + nostrClient.getWatchHistoryFingerprint; + +nostrClient.watchHistoryCache = new Map(); +nostrClient.watchHistoryStorage = { + version: 2, + actors: {}, }; -globalThis.window.NostrTools.signEvent = (event, privateKey) => - `sig-${privateKey.slice(0, 8)}-${event.created_at}`; +function cloneEvent(event) { + return JSON.parse(JSON.stringify(event)); +} -delete globalThis.window.NostrTools.nip04; -delete globalThis.window.nostr; - -const { nostrClient } = await import("../js/nostr.js"); - -function createPublishingClient(actorPubkey) { - const client = new nostrClient.constructor(); +function createFakeSimplePool() { + let resolver = () => ({ ok: true }); const publishedEvents = []; - const payloads = []; - client.pool = { - publish(urls, event) { - publishedEvents.push(event); - return { - on(eventName, handler) { - if (eventName === "ok" || eventName === "seen") { - setTimeout(handler, 0); + const publishLog = []; + let callIndex = 0; + + const pool = { + publish(relays, event) { + const normalizedRelays = Array.isArray(relays) ? [...relays] : []; + const clonedEvent = cloneEvent(event); + const entry = { + index: callIndex += 1, + relays: normalizedRelays, + event: clonedEvent, + handlers: {}, + }; + publishLog.push(entry); + + const handle = { + on(type, handler) { + if (typeof handler === "function") { + entry.handlers[type] = handler; } + return handle; }, }; + + setTimeout(() => { + let outcome; + try { + outcome = resolver({ + index: entry.index, + relays: normalizedRelays, + event: clonedEvent, + }); + } catch (error) { + outcome = { ok: false, error }; + } + if (outcome && outcome.ok) { + publishedEvents.push(cloneEvent(clonedEvent)); + if (typeof entry.handlers.ok === "function") { + entry.handlers.ok(); + } + } else if (typeof entry.handlers.failed === "function") { + entry.handlers.failed( + outcome?.error || new Error("publish failed"), + ); + } + }, 0); + + return handle; + }, + + list(relays, filters) { + const matched = []; + const listFilters = Array.isArray(filters) ? filters : []; + for (const filter of listFilters) { + const matches = publishedEvents.filter((event) => { + if ( + Array.isArray(filter?.kinds) && + !filter.kinds.includes(event.kind) + ) { + return false; + } + if ( + Array.isArray(filter?.authors) && + !filter.authors.includes(event.pubkey) + ) { + return false; + } + if (Array.isArray(filter?.["#d"])) { + const dValues = (event.tags || []) + .filter( + (tag) => + Array.isArray(tag) && + tag[0] === "d" && + typeof tag[1] === "string", + ) + .map((tag) => tag[1]); + if (!dValues.some((value) => filter["#d"].includes(value))) { + return false; + } + } + if (Array.isArray(filter?.["#snapshot"])) { + const snapshotValues = (event.tags || []) + .filter( + (tag) => + Array.isArray(tag) && + tag[0] === "snapshot" && + typeof tag[1] === "string", + ) + .map((tag) => tag[1]); + if ( + !snapshotValues.some((value) => filter["#snapshot"].includes(value)) + ) { + return false; + } + } + return true; + }); + const limited = Number.isFinite(filter?.limit) + ? matches.slice(0, Math.max(0, Math.floor(filter.limit))) + : matches; + matched.push(limited.map((event) => cloneEvent(event))); + } + return Promise.resolve(matched); + }, + + reset() { + publishedEvents.length = 0; + publishLog.length = 0; + callIndex = 0; + }, + + setResolver(fn) { + resolver = typeof fn === "function" ? fn : () => ({ ok: true }); + }, + + getEvents() { + return publishedEvents.map((event) => cloneEvent(event)); + }, + + getPublishLog() { + return publishLog.map((entry) => ({ + index: entry.index, + relays: [...entry.relays], + event: cloneEvent(entry.event), + })); }, - 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 = () => {}; - client.cancelWatchHistoryRepublish = () => {}; - client.scheduleWatchHistoryRepublish = () => {}; - return { client, publishedEvents, payloads }; + + return pool; } -function createDecryptClient(actorPubkey) { - const client = new nostrClient.constructor(); - client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; - client.ensureSessionActor = async () => actorPubkey; - return client; -} +const poolHarness = createFakeSimplePool(); +nostrClient.pool = poolHarness; +nostrClient.relays = ["wss://relay.test"]; +nostrClient.readRelays = ["wss://relay.test"]; +nostrClient.writeRelays = ["wss://relay.test"]; -const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -const { client: publishingClient, publishedEvents, payloads } = - createPublishingClient(ACTOR); - -publishingClient.watchHistoryPayloadMaxBytes = 400; -publishingClient.watchHistoryFetchEventLimit = 10; - -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, - }; - } +function installSessionCrypto({ privateKey }) { + const original = window.NostrTools || {}; + let encryptCalls = 0; + let decryptCalls = 0; + window.NostrTools = { + ...original, + getEventHash: (event) => + `hash-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, + signEvent: (event, key) => ({ + ...event, + id: `signed-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, + sig: `sig-${key}`, + }), + nip04: { + ...(original.nip04 || {}), + encrypt: async (secret, pub, plaintext) => { + encryptCalls += 1; + const payload = Buffer.from(plaintext, "utf8").toString("base64"); + return `session:${secret}:${pub}:${payload}`; + }, + decrypt: async (secret, pub, ciphertext) => { + decryptCalls += 1; + const prefix = `session:${secret}:${pub}:`; + if (!ciphertext.startsWith(prefix)) { + throw new Error("invalid-session-ciphertext"); + } + const encoded = ciphertext.slice(prefix.length); + return Buffer.from(encoded, "base64").toString("utf8"); + }, + }, + }; return { - type: "a", - value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`, - relay, - watchedAt, + restore() { + window.NostrTools = original; + }, + getEncryptCalls: () => encryptCalls, + getDecryptCalls: () => decryptCalls, }; -}); - -const publishResult = await publishingClient.publishWatchHistorySnapshot( - ACTOR, - longPointers -); - -assert.equal(publishResult.ok, true, "publish should report success"); -assert(publishResult.events.length > 1, "snapshot should chunk into multiple events"); -assert.equal( - publishResult.events.length, - payloads.length, - "each chunk should produce an encryption 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 chunks should share a snapshot id"); - -const headTag = publishResult.event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "head" -); -assert(headTag, "head chunk should include head tag"); - -const chunkIdentifiers = publishResult.events.map((event) => - event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] -); - -assert.equal( - chunkIdentifiers.length, - publishResult.events.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, - "each chunk should have a unique d tag" -); - -chunkIdentifiers.slice(1).forEach((identifier, index) => { - assert( - identifier && identifier.startsWith("watch-history:"), - `chunk ${index + 1} should use a watch-history namespace identifier` - ); - assert.notEqual( - identifier, - WATCH_HISTORY_LIST_IDENTIFIER, - "only the head chunk should use the canonical identifier" - ); -}); - -const headATags = publishResult.event.tags - .filter((tag) => Array.isArray(tag) && tag[0] === "a") - .map((tag) => tag[1]); - -const expectedChunkAddresses = publishResult.events.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( - headATags.includes(address), - `head chunk should reference ${address}` - ); -}); - -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 = 10; -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(); +function installExtensionCrypto({ actor }) { + const originalNostr = window.nostr; + const originalTools = window.NostrTools || {}; + let extensionEncrypts = 0; + let extensionDecrypts = 0; + let fallbackEncrypts = 0; + window.nostr = { + signEvent: async (event) => ({ + ...event, + id: `ext-${event.kind}-${event.created_at}`, + sig: "ext-signature", + }), + nip04: { + encrypt: async (target, plaintext) => { + extensionEncrypts += 1; + const payload = Buffer.from(plaintext, "utf8").toString("base64"); + return `extension:${target}:${payload}`; + }, + decrypt: async (target, ciphertext) => { + extensionDecrypts += 1; + const prefix = `extension:${target}:`; + if (!ciphertext.startsWith(prefix)) { + throw new Error("invalid-extension-ciphertext"); + } + const encoded = ciphertext.slice(prefix.length); + return Buffer.from(encoded, "base64").toString("utf8"); + }, + }, + }; + window.NostrTools = { + ...originalTools, + nip04: { + ...(originalTools.nip04 || {}), + encrypt: () => { + fallbackEncrypts += 1; + throw new Error("fallback-encrypt-used"); + }, + decrypt: async (secret, pub, ciphertext) => { + const prefix = `session:${secret}:${pub}:`; + if (!ciphertext.startsWith(prefix)) { + throw new Error("invalid-session-ciphertext"); + } + const encoded = ciphertext.slice(prefix.length); + return Buffer.from(encoded, "base64").toString("utf8"); + }, + }, + }; + return { + restore() { + window.nostr = originalNostr; + window.NostrTools = originalTools; + }, + getExtensionEncrypts: () => extensionEncrypts, + getExtensionDecrypts: () => extensionDecrypts, + getFallbackEncrypts: () => fallbackEncrypts, + }; } -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" -)?.[1]; - -const freshClient = createDecryptClient(ACTOR); -freshClient.relays = ["wss://unit.test"]; -freshClient.watchHistoryFetchEventLimit = 10; - -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 [headEvent]; - } - - 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) { - 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(event); - continue; - } - if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) { - responses.push(event); - } - } - - 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.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); +function extractChunkIdentifier(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") { + return tag[1]; } } - if (Array.isArray(filter?.["#snapshot"])) { - snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId); - } + return ""; } -assert( - chunkEvents.every((event) => { - const identifier = event.tags.find( - (tag) => Array.isArray(tag) && tag[0] === "d" - )?.[1]; - return ( - identifiersRequested.has(identifier) || - (snapshotId && snapshotRequested) +function maxCreatedAt(events = []) { + return events.reduce((max, event) => { + if (!event || typeof event !== "object") { + return max; + } + const created = Number.isFinite(event.created_at) ? event.created_at : 0; + return created > max ? created : max; + }, 0); +} + +async function testPublishSnapshotCanonicalizationAndChunking() { + poolHarness.reset(); + poolHarness.setResolver(() => ({ ok: true })); + + const actor = "session-pubkey"; + const restoreCrypto = installSessionCrypto({ privateKey: "session-priv" }); + + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + const originalLastCreated = nostrClient.watchHistoryLastCreatedAt; + const originalDateNow = Date.now; + + try { + nostrClient.pubkey = ""; + nostrClient.sessionActor = { pubkey: actor, privateKey: "session-priv" }; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.watchHistoryLastCreatedAt = 0; + + let nowValue = 1_700_000_000_000; + Date.now = () => nowValue; + + const hugeValueA = `event-${"a".repeat(35000)}`; + const hugeValueB = `event-${"b".repeat(35000)}`; + const rawItems = [ + { type: "e", value: hugeValueB, watchedAt: 210 }, + { type: "e", value: hugeValueA, watchedAt: 200 }, + { type: "e", value: "pointer-dup", watchedAt: 100 }, + { type: "e", value: "pointer-dup", watchedAt: 150 }, + { type: "a", value: "30023:pub:episode", relay: "wss://relay.one", watchedAt: 90 }, + { tag: ["a", "30023:pub:episode", "wss://relay.two"] }, + { type: "e", value: "pointer-small", watchedAt: 80 }, + ]; + + const firstResult = await nostrClient.publishWatchHistorySnapshot(rawItems, { + actorPubkey: actor, + snapshotId: "session-snapshot", + }); + + assert.ok(firstResult.ok, "snapshot should succeed with session crypto"); + assert.equal( + firstResult.items.length, + 5, + "canonicalization should dedupe pointer entries by key", + ); + const dupMatches = firstResult.items.filter( + (item) => item.value === "pointer-dup", + ); + assert.equal( + dupMatches.length, + 1, + "duplicate pointer should be collapsed into a single canonical entry", + ); + assert.equal( + dupMatches[0].watchedAt, + 150, + "canonical pointer should retain the newest watchedAt timestamp", + ); + const anchorPointer = firstResult.items.find( + (item) => item.value === "30023:pub:episode", + ); + assert.ok(anchorPointer, "address pointer should survive normalization"); + assert.equal( + anchorPointer.relay, + "wss://relay.one", + "existing relay metadata should persist when deduping", ); - }), - "follow-up query should target chunk identifiers or snapshot" -); -const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; -const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = - createPublishingClient(MONOTONIC_ACTOR); + assert.equal( + firstResult.chunkEvents.length, + 2, + "large payload should be chunked across two encrypted events", + ); -const monotonicOriginalDateNow = Date.now; -try { - const monotonicBaseSeconds = 1_700_123_456; - Date.now = () => monotonicBaseSeconds * 1000; + const decrypt = window.NostrTools?.nip04?.decrypt; + assert.equal( + typeof decrypt, + "function", + "session decrypt stub should be installed", + ); - const firstMonotonicPointers = [ - { type: "e", value: "event-monotonic-1", relay: null }, - { type: "e", value: "event-monotonic-2", relay: null }, - ]; + for (const chunkEvent of firstResult.chunkEvents) { + assert.notEqual( + chunkEvent.content.trim()[0], + "{", + "chunk content must remain encrypted and avoid plaintext fallbacks", + ); + const plaintext = await decrypt( + "session-priv", + actor, + chunkEvent.content, + ); + const payload = JSON.parse(plaintext); + assert.equal(payload.snapshot, "session-snapshot"); + assert(Array.isArray(payload.items), "chunk payload should include items"); + const serializedLength = plaintext.length; + assert( + serializedLength <= WATCH_HISTORY_PAYLOAD_MAX_BYTES, + `chunk payload should respect WATCH_HISTORY_PAYLOAD_MAX_BYTES (observed ${serializedLength})`, + ); + } - const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( - MONOTONIC_ACTOR, - firstMonotonicPointers - ); + const pointerAddresses = firstResult.pointerEvent.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "a", + ); + assert.equal( + pointerAddresses.length, + 2, + "pointer event should reference each published chunk", + ); - const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry( - firstSnapshot.event, - firstMonotonicPointers, - Date.now() - ); - monotonicPublishingClient.watchHistoryCache.set( - MONOTONIC_ACTOR, - baselineEntry - ); + const firstCreatedMax = maxCreatedAt([ + ...firstResult.chunkEvents, + firstResult.pointerEvent, + ]); - const secondMonotonicPointers = [ - { type: "e", value: "event-monotonic-3", relay: null }, - { type: "e", value: "event-monotonic-1", relay: null }, - ]; + const fingerprintOne = await nostrClient.getWatchHistoryFingerprint( + actor, + firstResult.items, + ); + assert.equal( + typeof fingerprintOne, + "string", + "fingerprint generation should yield a deterministic digest", + ); - const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( - MONOTONIC_ACTOR, - secondMonotonicPointers, - baselineEntry - ); + nowValue -= 120_000; + const secondResult = await nostrClient.publishWatchHistorySnapshot( + [{ type: "e", value: "second-pointer", watchedAt: 50 }], + { actorPubkey: actor, snapshotId: "session-followup" }, + ); + assert.ok( + secondResult.ok, + "follow-up snapshot should succeed when time moves backwards", + ); + const secondCreatedMin = maxCreatedAt([ + ...secondResult.chunkEvents, + secondResult.pointerEvent, + ]); + assert( + secondCreatedMin > firstCreatedMax, + "created_at guard should enforce monotonic timestamps between snapshots", + ); + const fingerprintTwo = await nostrClient.getWatchHistoryFingerprint( + actor, + secondResult.items, + ); + assert.notEqual( + fingerprintOne, + fingerprintTwo, + "fingerprint should change when canonical items differ", + ); - 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; + const chunkingPreview = chunkWatchHistoryPayloadItems( + firstResult.items, + "preview", + 40_000, + ); + assert.equal( + chunkingPreview.chunks.length, + 2, + "chunk helper should split payloads when the configured limit is smaller than the canonical snapshot", + ); + assert.equal( + chunkingPreview.skipped.length, + 0, + "no canonical entries should be skipped when the limit exceeds individual pointer size", + ); + } finally { + restoreCrypto.restore(); + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + nostrClient.watchHistoryLastCreatedAt = originalLastCreated; + Date.now = originalDateNow; + } } -localStorage.clear(); +async function testPublishSnapshotUsesExtensionCrypto() { + poolHarness.reset(); + poolHarness.setResolver(() => ({ ok: true })); -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 actor = "ext-pubkey"; + const sessionRestore = installSessionCrypto({ privateKey: "session-priv" }); + const extension = installExtensionCrypto({ actor }); + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; - const updateResult = await updateClient.updateWatchHistoryList({ - type: "e", - value: "update-pointer", - relay: null, - }); + try { + nostrClient.pubkey = actor; + nostrClient.sessionActor = null; + nostrClient.ensureSessionActor = async () => actor; - 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 result = await nostrClient.publishWatchHistorySnapshot( + [ + { type: "e", value: "ext-pointer-1", watchedAt: 100 }, + { type: "e", value: "ext-pointer-2", watchedAt: 50 }, + ], + { actorPubkey: actor, snapshotId: "extension" }, + ); + + assert.ok(result.ok, "extension-driven snapshot should succeed"); + assert.equal( + extension.getFallbackEncrypts(), + 0, + "session fallback encryptor should remain unused when extension path is active", + ); + assert(extension.getExtensionEncrypts() > 0, "extension encrypt should be invoked"); + + const decrypt = window.nostr?.nip04?.decrypt; + assert.equal( + typeof decrypt, + "function", + "extension decrypt helper should be available", + ); + + const decrypted = await decrypt(actor, result.chunkEvents[0].content); + const payload = JSON.parse(decrypted); + assert.equal(payload.snapshot, "extension"); + assert.equal( + payload.items.length, + result.items.length, + "decrypted payload should match canonical item count", + ); + } finally { + extension.restore(); + sessionRestore.restore(); + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + } } -const REMOVE_ACTOR = `${ACTOR}-remove`; -const { - client: removalClient, - payloads: removalPayloads, -} = createPublishingClient(REMOVE_ACTOR); +async function testPublishSnapshotFailureRetry() { + poolHarness.reset(); + nostrClient.watchHistoryLastCreatedAt = 0; -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, - }, -]; + const actor = "retry-actor"; + const restoreCrypto = installSessionCrypto({ privateKey: "retry-priv" }); + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; -await removalClient.publishWatchHistorySnapshot( - REMOVE_ACTOR, - removalPointers -); + try { + nostrClient.pubkey = ""; + nostrClient.sessionActor = { pubkey: actor, privateKey: "retry-priv" }; + nostrClient.ensureSessionActor = async () => actor; -const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`; -const removalResult = await removalClient.removeWatchHistoryItem(removalKey); + let failureCount = 0; + poolHarness.setResolver(({ event }) => { + const identifier = extractChunkIdentifier(event); + if (identifier.endsWith(":0")) { + failureCount += 1; + return { ok: false, error: new Error("relay-rejection") }; + } + return { ok: true }; + }); -assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed"); -assert.equal( - removalResult.removedKey, - removalKey, - "removeWatchHistoryItem should echo the removed pointer key" -); + const failed = await nostrClient.publishWatchHistorySnapshot( + [{ type: "e", value: "retry-pointer", watchedAt: 42 }], + { actorPubkey: actor, snapshotId: "retry" }, + ); -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" -); + assert.equal(failed.ok, false, "snapshot should surface relay rejections"); + assert.equal(failed.retryable, true, "chunk rejection should be retryable"); + assert.equal( + failureCount, + 1, + "resolver should be invoked for the failed chunk", + ); -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; + poolHarness.setResolver(() => ({ ok: true })); + const succeeded = await nostrClient.publishWatchHistorySnapshot( + [{ type: "e", value: "retry-pointer", watchedAt: 43 }], + { actorPubkey: actor, snapshotId: "retry" }, + ); + assert.ok(succeeded.ok, "subsequent snapshot should succeed after failure"); + assert( + poolHarness.getPublishLog().length >= 3, + "publish harness should record chunk and pointer attempts", + ); + } finally { + restoreCrypto.restore(); + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + } } -console.log("watch history tests passed"); +async function testWatchHistoryPartialRelayRetry() { + poolHarness.reset(); + watchHistoryService.resetProgress(); + nostrClient.watchHistoryLastCreatedAt = 0; + + const actor = "partial-actor"; + const restoreCrypto = installSessionCrypto({ privateKey: "partial-priv" }); + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot; + const originalSchedule = nostrClient.scheduleWatchHistoryRepublish; + const originalRecordView = nostrClient.recordVideoView; + + try { + nostrClient.pubkey = actor; + nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv" }; + nostrClient.ensureSessionActor = async () => actor; + + const relaySet = [ + "wss://relay.one", + "wss://relay.two", + "wss://relay.three", + ]; + nostrClient.relays = [...relaySet]; + nostrClient.writeRelays = [...relaySet]; + + let attemptIndex = 0; + let currentAttempt = 0; + nostrClient.publishWatchHistorySnapshot = async function publishWithTracking( + ...args + ) { + attemptIndex += 1; + currentAttempt = attemptIndex; + return originalPublishSnapshot.apply(this, args); + }; + + nostrClient.recordVideoView = async () => ({ + ok: true, + event: { + id: `view-${Date.now()}`, + pubkey: actor, + created_at: Math.floor(Date.now() / 1000), + }, + }); + + await watchHistoryService.publishView( + { type: "e", value: "partial-pointer" }, + Math.floor(Date.now() / 1000), + { actor }, + ); + + const queuedBefore = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queuedBefore.length, + 1, + "queue should contain the partial pointer before snapshot", + ); + assert.deepEqual( + nostrClient.writeRelays, + relaySet, + "write relays should include all configured endpoints", + ); + + const attemptPlans = [ + { + "wss://relay.one": true, + "wss://relay.two": true, + "wss://relay.three": false, + }, + { + "wss://relay.one": true, + "wss://relay.two": false, + "wss://relay.three": true, + }, + { + "wss://relay.one": true, + "wss://relay.two": true, + "wss://relay.three": true, + }, + ]; + + poolHarness.setResolver(({ relays }) => { + const relayUrl = Array.isArray(relays) && relays.length ? relays[0] : ""; + const index = Math.min( + Math.max(currentAttempt, 1) - 1, + attemptPlans.length - 1, + ); + const plan = attemptPlans[index] || {}; + const accept = plan?.[relayUrl]; + if (accept) { + return { ok: true }; + } + return { ok: false, error: new Error(`reject-${relayUrl || "unknown"}`) }; + }); + + const scheduledRuns = []; + nostrClient.scheduleWatchHistoryRepublish = (snapshotId, operation) => { + const promise = (async () => { + let attempt = 1; + let result = null; + for (; attempt <= attemptPlans.length + 1; attempt += 1) { + result = await operation(attempt); + if (result?.ok || !result?.retryable) { + break; + } + } + return result; + })(); + scheduledRuns.push({ snapshotId, promise }); + return { attempt: 1, delay: 0 }; + }; + + let thrownError = null; + try { + await watchHistoryService.snapshot(null, { actor, reason: "partial-test" }); + } catch (error) { + thrownError = error; + } + + assert(thrownError, "snapshot should throw when partial acceptance occurs"); + assert.equal( + thrownError?.result?.retryable, + true, + "partial failures should be marked retryable", + ); + assert.equal( + thrownError?.result?.error, + "partial-relay-acceptance", + "partial failures should expose the partial acceptance error code", + ); + assert(thrownError?.result?.partial, "result should report partial acceptance"); + + const initialPointerStatus = + thrownError?.result?.publishResults?.relayStatus?.pointer || []; + assert( + initialPointerStatus.some((entry) => entry && entry.success === false), + "initial relay status should capture pointer rejections", + ); + + assert( + attemptIndex >= 1, + "initial snapshot attempt should increment the attempt counter", + ); + assert.equal( + scheduledRuns.length, + 1, + "partial failure should schedule a republish operation", + ); + + const finalResult = await scheduledRuns[0].promise; + assert(finalResult?.ok, "republish attempts should converge to success"); + assert.equal( + attemptIndex, + 3, + "republish loop should retry until every relay accepts", + ); + assert.equal( + finalResult?.partial, + false, + "final result should not mark the publish as partial", + ); + + const finalPointerStatus = + finalResult?.publishResults?.relayStatus?.pointer || []; + assert.equal( + finalPointerStatus.filter((entry) => entry?.success).length, + relaySet.length, + "final pointer publish should succeed on all relays", + ); + for (const chunkStatus of finalResult?.publishResults?.relayStatus?.chunks || []) { + assert.equal( + chunkStatus.filter((entry) => entry?.success).length, + relaySet.length, + "final chunk publish should succeed on all relays", + ); + } + + const remainingQueue = watchHistoryService.getQueuedPointers(actor); + assert.equal( + remainingQueue.length, + 0, + "queue should remain empty after successful retries", + ); + } finally { + nostrClient.scheduleWatchHistoryRepublish = originalSchedule; + nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.recordVideoView = originalRecordView; + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + poolHarness.setResolver(() => ({ ok: true })); + watchHistoryService.resetProgress(actor); + restoreCrypto.restore(); + } +} + +async function testResolveWatchHistoryBatchingWindow() { + const actor = "batch-window-actor"; + const originalEnsure = nostrClient.ensureSessionActor; + const originalPub = nostrClient.pubkey; + const originalFetch = nostrClient.fetchWatchHistory; + const originalCache = nostrClient.watchHistoryCache; + const originalStorage = nostrClient.watchHistoryStorage; + + try { + nostrClient.pubkey = ""; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.watchHistoryCache = new Map(); + nostrClient.watchHistoryStorage = { version: 2, actors: {} }; + + const syntheticItems = Array.from({ length: 24 }, (_, index) => ({ + type: "e", + value: `pointer-${index}`, + watchedAt: 1_700_100_000 + index, + })); + + nostrClient.fetchWatchHistory = async () => ({ + items: syntheticItems.map((item) => ({ ...item })), + snapshotId: "batch-snapshot", + pointerEvent: null, + }); + + const resolved = await nostrClient.resolveWatchHistory(actor, { + forceRefresh: true, + }); + + assert.equal( + resolved.length, + Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length), + "batched resolve should return the full window when no page size override is configured", + ); + assert(resolved.length > 1, "resolved history should include multiple entries"); + assert.equal( + resolved[0]?.value, + "pointer-23", + "resolved items should remain sorted by newest watchedAt value", + ); + const uniqueValues = new Set(resolved.map((item) => item?.value)); + assert.equal( + uniqueValues.size, + resolved.length, + "resolved history should retain all unique pointers", + ); + } finally { + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.pubkey = originalPub; + nostrClient.fetchWatchHistory = originalFetch; + nostrClient.watchHistoryCache = originalCache; + nostrClient.watchHistoryStorage = originalStorage; + } +} + +async function testWatchHistoryServiceIntegration() { + poolHarness.reset(); + poolHarness.setResolver(() => ({ ok: true })); + + const actor = "service-actor"; + const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" }); + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + const originalWatchHistoryCacheTtl = nostrClient.watchHistoryCacheTtlMs; + + try { + nostrClient.pubkey = "npub-logged"; + nostrClient.sessionActor = { pubkey: actor, privateKey: "service-priv" }; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.watchHistoryLastCreatedAt = 0; + nostrClient.watchHistoryCache.clear(); + nostrClient.watchHistoryFingerprints = new Map(); + nostrClient.watchHistoryCacheTtlMs = 60_000; + + localStorage.clear(); + watchHistoryService.resetProgress(); + + let viewCreatedAt = 1_700_000_010; + nostrClient.recordVideoView = async (_pointer, options = {}) => ({ + ok: true, + event: { + id: `view-${viewCreatedAt}`, + pubkey: actor, + created_at: options.created_at || viewCreatedAt, + }, + }); + + const beforeFingerprint = await watchHistoryService.getFingerprint(actor); + assert.equal( + typeof beforeFingerprint, + "string", + "fingerprint lookup should always return a string value", + ); + + await watchHistoryService.publishView( + { type: "e", value: "video-one" }, + viewCreatedAt, + { actor }, + ); + viewCreatedAt += 60; + await watchHistoryService.publishView( + { type: "e", value: "video-one" }, + viewCreatedAt, + { actor }, + ); + viewCreatedAt += 30; + await watchHistoryService.publishView( + { type: "a", value: "30023:pub:episode" }, + viewCreatedAt, + { actor }, + ); + + const queued = watchHistoryService.getQueuedPointers(actor); + assert.equal(queued.length, 2, "queue should dedupe repeated pointers"); + + const snapshotResult = await watchHistoryService.snapshot(null, { + actor, + reason: "integration", + }); + assert.ok(snapshotResult.ok, "snapshot should publish queued pointers"); + assert.equal( + watchHistoryService.getQueuedPointers(actor).length, + 0, + "queue should be cleared after successful snapshot", + ); + + const resolvedItems = await watchHistoryService.loadLatest(actor); + assert.deepEqual( + resolvedItems, + snapshotResult.items, + "loadLatest should return decrypted canonical pointers", + ); + assert(resolvedItems[0].watchedAt >= resolvedItems[1].watchedAt); + assert.equal( + resolvedItems[0].session, + true, + "session flag should persist through publish and load", + ); + + const afterFingerprint = await watchHistoryService.getFingerprint(actor); + assert.notEqual( + afterFingerprint, + beforeFingerprint, + "fingerprint should update after publishing a snapshot", + ); + + const latestFingerprint = await nostrClient.getWatchHistoryFingerprint( + actor, + resolvedItems, + ); + assert.equal( + afterFingerprint, + latestFingerprint, + "service fingerprint cache should align with canonical digest", + ); + } finally { + restoreCrypto.restore(); + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + nostrClient.watchHistoryCacheTtlMs = originalWatchHistoryCacheTtl; + nostrClient.watchHistoryCache.clear(); + nostrClient.watchHistoryFingerprints = new Map(); + } +} + +async function testWatchHistoryLocalFallbackWhenDisabled() { + console.log("Running watch history local fallback test..."); + + const actor = "local-fallback-actor"; + const originalRecordView = nostrClient.recordVideoView; + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = ""; + nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv" }; + nostrClient.recordVideoView = async (_pointer, options = {}) => ({ + ok: true, + event: { + id: "local-view", + pubkey: actor, + created_at: options.created_at || 1_700_500_000, + }, + }); + + const supported = + typeof watchHistoryService.supportsLocalHistory === "function" + ? watchHistoryService.supportsLocalHistory(actor) + : true; + const localOnly = + typeof watchHistoryService.isLocalOnly === "function" + ? watchHistoryService.isLocalOnly(actor) + : true; + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + supported, + true, + "service should report local history support while sync disabled", + ); + assert.equal(localOnly, true, "guest session should be treated as local only"); + assert.equal( + enabled, + false, + "sync should be disabled for session actors when feature flag is off", + ); + + const createdAt = 1_700_500_000; + await watchHistoryService.publishView( + { type: "e", value: "local-pointer" }, + createdAt, + { actor }, + ); + + const queued = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queued.length, + 1, + "local queue should retain pointer when sync is disabled", + ); + + const latest = await watchHistoryService.loadLatest(actor); + assert.equal( + latest.length, + 1, + "loadLatest should surface session queue entries when sync disabled", + ); + assert.equal(latest[0]?.value, "local-pointer"); + assert.equal(latest[0]?.type, "e"); + assert( + Number.isFinite(latest[0]?.watchedAt) && latest[0].watchedAt > 0, + "local fallback entries should carry watchedAt timestamps", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.recordVideoView = originalRecordView; + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + } +} + +async function testWatchHistorySyncEnabledForLoggedInUsers() { + console.log("Running watch history logged-in sync override test..."); + + const actor = "npub-logged-sync"; + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + const originalResolve = nostrClient.resolveWatchHistory; + const originalFingerprint = nostrClient.getWatchHistoryFingerprint; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = actor; + nostrClient.sessionActor = null; + + let resolveCalls = 0; + nostrClient.resolveWatchHistory = async (requestedActor, options = {}) => { + resolveCalls += 1; + assert.equal( + requestedActor, + actor, + "resolveWatchHistory should target the logged-in actor", + ); + assert.equal( + options?.forceRefresh, + true, + "loadLatest should force a relay refresh for logged-in actors", + ); + return [ + { + type: "e", + value: "remote-pointer", + watchedAt: 1_700_600_000, + }, + ]; + }; + + let fingerprintCalls = 0; + nostrClient.getWatchHistoryFingerprint = async (requestedActor, items) => { + fingerprintCalls += 1; + assert.equal( + requestedActor, + actor, + "fingerprint lookup should use the logged-in actor", + ); + assert(Array.isArray(items), "fingerprint helper expects item array input"); + return "fingerprint-logged"; + }; + + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + enabled, + true, + "sync should remain enabled for logged-in actors even when the flag is disabled", + ); + + const items = await watchHistoryService.loadLatest(actor); + assert.equal( + resolveCalls > 0, + true, + "loadLatest should fetch from relays for logged-in actors", + ); + assert.equal(items.length, 1, "loadLatest should return relay data for logged-in actors"); + assert.equal(items[0]?.value, "remote-pointer"); + assert.equal( + fingerprintCalls > 0, + true, + "fingerprint cache should update for logged-in actors", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.resolveWatchHistory = originalResolve; + nostrClient.getWatchHistoryFingerprint = originalFingerprint; + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + } +} + +async function testWatchHistoryAppLoginFallback() { + console.log("Running watch history app login fallback test..."); + + const actor = "f".repeat(64); + const originalPub = nostrClient.pubkey; + const originalSession = nostrClient.sessionActor; + const originalApp = typeof window.app === "undefined" ? undefined : window.app; + + try { + setWatchHistoryV2Enabled(false); + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = ""; + nostrClient.sessionActor = null; + window.app = { + pubkey: actor, + normalizeHexPubkey(value) { + if (typeof value === "string" && value.trim()) { + return value.trim().toLowerCase(); + } + return null; + }, + }; + + const enabled = + typeof watchHistoryService.isEnabled === "function" + ? watchHistoryService.isEnabled(actor) + : false; + assert.equal( + enabled, + true, + "sync should be enabled when the app reports a logged-in pubkey", + ); + } finally { + setWatchHistoryV2Enabled(true); + watchHistoryService.resetProgress(); + nostrClient.pubkey = originalPub; + nostrClient.sessionActor = originalSession; + if (typeof originalApp === "undefined") { + delete window.app; + } else { + window.app = originalApp; + } + } +} + +await testPublishSnapshotCanonicalizationAndChunking(); +await testPublishSnapshotUsesExtensionCrypto(); +await testPublishSnapshotFailureRetry(); +await testWatchHistoryPartialRelayRetry(); +await testResolveWatchHistoryBatchingWindow(); +await testWatchHistoryServiceIntegration(); +await testWatchHistoryLocalFallbackWhenDisabled(); +await testWatchHistorySyncEnabledForLoggedInUsers(); +await testWatchHistoryAppLoginFallback(); + +console.log("watch-history.test.mjs completed successfully"); + +window.nostr = originalWindowNostr; +window.NostrTools = originalNostrTools; +nostrClient.pool = originalPool; +nostrClient.relays = originalRelays; +nostrClient.readRelays = originalReadRelays; +nostrClient.writeRelays = originalWriteRelays; +nostrClient.ensureSessionActor = originalEnsureSessionActor; +nostrClient.sessionActor = originalSessionActor; +nostrClient.pubkey = originalPubkey; +nostrClient.watchHistoryLastCreatedAt = originalWatchHistoryLastCreatedAt; +nostrClient.recordVideoView = originalRecordVideoView; +nostrClient.watchHistoryCache = originalWatchHistoryCache; +nostrClient.watchHistoryStorage = originalWatchHistoryStorage; +nostrClient.scheduleWatchHistoryRepublish = + originalScheduleWatchHistoryRepublish; +nostrClient.resolveWatchHistory = originalResolveWatchHistory; +nostrClient.getWatchHistoryFingerprint = + originalGetWatchHistoryFingerprint; + +if (!originalFlag) { + setWatchHistoryV2Enabled(false); +} diff --git a/views/history.html b/views/history.html index f5d08587..0b1fb7c8 100644 --- a/views/history.html +++ b/views/history.html @@ -1,26 +1,107 @@
-
-

Watch History

-
-
+
-
+

Watch History

+

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

+
+
+ + +
+
+ + + +
+ Thumbnails and titles are hydrated locally from your device caches. +
+ +
+ +
+
+ +
+
+ -
+ + + + +
diff --git a/views/most-recent-videos.html b/views/most-recent-videos.html index d7b66acb..0f65c6bb 100644 --- a/views/most-recent-videos.html +++ b/views/most-recent-videos.html @@ -8,6 +8,11 @@ id="videoList" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8" > - +
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; " - > + > + +