From 73a7a48c5c0a807a839cab15ba4455bba94ff29e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Oct 2025 21:44:50 -0400 Subject: [PATCH] Add NWC settings persistence with per-profile isolation --- js/app.js | 165 +++++++++++++++++++++ js/nwcSettings.js | 368 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 js/nwcSettings.js diff --git a/js/app.js b/js/app.js index 0651d222..710ba279 100644 --- a/js/app.js +++ b/js/app.js @@ -43,6 +43,12 @@ import { formatViewCount, ingestLocalViewEvent, } from "./viewCounter.js"; +import { + loadNwcSettings, + saveNwcSettings, + clearNwcSettings, + createDefaultNwcSettings, +} from "./nwcSettings.js"; import { formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, formatTimeAgo as formatTimeAgoUtil, @@ -486,6 +492,8 @@ class Application { this.moreMenuGlobalHandlerBound = false; this.boundMoreMenuDocumentClick = null; this.boundMoreMenuDocumentKeydown = null; + this.nwcSettings = new Map(); + this.boundNwcSettingsToastHandler = null; Object.defineProperty(this, "savedProfiles", { configurable: false, enumerable: false, @@ -519,6 +527,30 @@ class Application { }, }); + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + this.boundNwcSettingsToastHandler = (event) => { + const detail = event?.detail || {}; + if (detail.source !== "nwc-settings") { + return; + } + const rawMessage = + typeof detail.message === "string" ? detail.message.trim() : ""; + const message = rawMessage || "Wallet settings storage issue detected."; + this.log(`[nwcSettings] ${message}`); + if (detail.variant === "warning") { + this.showStatus(message); + if (typeof window !== "undefined" && typeof window.setTimeout === "function") { + window.setTimeout(() => { + this.showStatus(""); + }, 5000); + } + } else { + this.showError(message); + } + }; + window.addEventListener("bitvid:toast", this.boundNwcSettingsToastHandler); + } + const videoListViewConfig = { document, mediaLoader: this.mediaLoader, @@ -3934,9 +3966,118 @@ class Application { } } + async hydrateNwcSettingsForPubkey(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return createDefaultNwcSettings(); + } + + try { + const settings = await loadNwcSettings(normalized); + const record = + settings && typeof settings === "object" + ? { ...settings } + : createDefaultNwcSettings(); + this.nwcSettings.set(normalized, record); + return { ...record }; + } catch (error) { + console.warn( + `[nwcSettings] Failed to load settings for ${normalized}:`, + error + ); + const fallback = createDefaultNwcSettings(); + this.nwcSettings.set(normalized, fallback); + return { ...fallback }; + } + } + + getActiveNwcSettings() { + const normalized = this.normalizeHexPubkey(this.pubkey); + if (!normalized) { + return createDefaultNwcSettings(); + } + const cached = this.nwcSettings.get(normalized); + return cached ? { ...cached } : createDefaultNwcSettings(); + } + + async updateActiveNwcSettings(partial = {}) { + const normalized = this.normalizeHexPubkey(this.pubkey); + if (!normalized) { + console.warn( + "[nwcSettings] Cannot update settings without an active pubkey." + ); + return createDefaultNwcSettings(); + } + + try { + const updated = await saveNwcSettings(normalized, partial); + const record = + updated && typeof updated === "object" + ? { ...updated } + : createDefaultNwcSettings(); + this.nwcSettings.set(normalized, record); + return { ...record }; + } catch (error) { + console.warn( + `[nwcSettings] Failed to save settings for ${normalized}:`, + error + ); + return this.getActiveNwcSettings(); + } + } + + async clearStoredNwcSettings(pubkey, { silent = false } = {}) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return false; + } + + try { + await clearNwcSettings(normalized); + } catch (error) { + console.warn( + `[nwcSettings] Failed to clear settings for ${normalized}:`, + error + ); + if (!silent) { + this.showError("Failed to clear wallet settings for this account."); + } + try { + await saveNwcSettings(normalized, createDefaultNwcSettings()); + } catch (persistError) { + console.warn( + `[nwcSettings] Failed to overwrite settings for ${normalized}:`, + persistError + ); + } + this.nwcSettings.delete(normalized); + return false; + } + + this.nwcSettings.delete(normalized); + return true; + } + async handleAuthLogin(detail = {}) { + const normalizedActive = this.normalizeHexPubkey( + detail?.pubkey || this.pubkey + ); + const normalizedPrevious = this.normalizeHexPubkey(detail?.previousPubkey); + if (detail?.identityChanged) { this.resetViewLoggingState(); + this.nwcSettings.clear(); + } + + if ( + normalizedPrevious && + (!normalizedActive || normalizedPrevious !== normalizedActive) + ) { + await this.clearStoredNwcSettings(normalizedPrevious, { silent: true }); + } + + if (normalizedActive) { + await this.hydrateNwcSettingsForPubkey(normalizedActive); } this.renderSavedProfiles(); @@ -3995,6 +4136,14 @@ class Application { async handleAuthLogout(detail = {}) { this.resetViewLoggingState(); + const normalizedPrevious = this.normalizeHexPubkey( + detail?.previousPubkey || detail?.pubkey || this.pubkey + ); + if (normalizedPrevious) { + await this.clearStoredNwcSettings(normalizedPrevious, { silent: false }); + } + this.nwcSettings.clear(); + this.renderSavedProfiles(); if (this.loginButton) { @@ -7750,6 +7899,22 @@ class Application { this.unsubscribeFromCurrentUserState = null; } + if ( + this.boundNwcSettingsToastHandler && + typeof window !== "undefined" && + typeof window.removeEventListener === "function" + ) { + window.removeEventListener( + "bitvid:toast", + this.boundNwcSettingsToastHandler + ); + this.boundNwcSettingsToastHandler = null; + } + + if (this.nwcSettings instanceof Map) { + this.nwcSettings.clear(); + } + if (typeof this.watchHistoryPreferenceUnsubscribe === "function") { try { this.watchHistoryPreferenceUnsubscribe(); diff --git a/js/nwcSettings.js b/js/nwcSettings.js new file mode 100644 index 00000000..5a1697f0 --- /dev/null +++ b/js/nwcSettings.js @@ -0,0 +1,368 @@ +const DB_NAME = "bitvidSettings"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const KEY_PREFIX = "nwcSettings:"; +const LOCALSTORAGE_PREFIX = "bitvid:nwcSettings:"; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const MIN_DEFAULT_ZAP = 0; +const MAX_DEFAULT_ZAP = 100000000; + +const DEFAULT_SETTINGS = Object.freeze({ + nwcUri: "", + defaultZap: null, + lastChecked: null, + version: "", +}); + +function createClone(value) { + return { ...value }; +} + +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (error) { + return false; + } +} + +function openSettingsDb() { + return new Promise((resolve, reject) => { + if (!isIndexedDbAvailable()) { + resolve(null); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open settings DB")); + }; + }); +} + +function normalizePubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1") && typeof window !== "undefined") { + try { + const decoded = window?.NostrTools?.nip19?.decode?.(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + // Ignore decode errors and fall through to return null. + } + } + + return null; +} + +function resolveStorageKeys(pubkey) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + throw new Error("Invalid pubkey for NWC settings."); + } + return { + normalized, + dbKey: `${KEY_PREFIX}${normalized}`, + fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`, + }; +} + +function sanitizeUri(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function sanitizeDefaultZap(value) { + if (value === null || value === undefined || value === "") { + return null; + } + + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return null; + } + + const rounded = Math.round(numeric); + if (!Number.isFinite(rounded)) { + return null; + } + + return Math.min(MAX_DEFAULT_ZAP, Math.max(MIN_DEFAULT_ZAP, rounded)); +} + +function sanitizeTimestamp(value) { + if (!Number.isFinite(value)) { + return null; + } + const rounded = Math.round(value); + if (!Number.isFinite(rounded)) { + return null; + } + return Math.max(0, rounded); +} + +function sanitizeVersion(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim(); +} + +function normalizeSettings(raw) { + const base = createClone(DEFAULT_SETTINGS); + if (!raw || typeof raw !== "object") { + return base; + } + + if (Object.prototype.hasOwnProperty.call(raw, "nwcUri")) { + base.nwcUri = sanitizeUri(raw.nwcUri); + } + + if (Object.prototype.hasOwnProperty.call(raw, "defaultZap")) { + base.defaultZap = sanitizeDefaultZap(raw.defaultZap); + } + + if (Object.prototype.hasOwnProperty.call(raw, "lastChecked")) { + base.lastChecked = sanitizeTimestamp(raw.lastChecked); + } + + if (Object.prototype.hasOwnProperty.call(raw, "version")) { + base.version = sanitizeVersion(raw.version); + } + + return base; +} + +function mergeSettings(existing, partial) { + const source = partial && typeof partial === "object" ? partial : {}; + return normalizeSettings({ ...existing, ...source }); +} + +function emitToast(message, { variant = "error", error } = {}) { + const text = typeof message === "string" ? message.trim() : ""; + if (!text) { + return; + } + if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { + try { + window.dispatchEvent( + new CustomEvent("bitvid:toast", { + detail: { + message: text, + variant, + source: "nwc-settings", + error, + }, + }) + ); + } catch (eventError) { + console.warn("[nwcSettings] Failed to dispatch toast event", eventError); + } + } +} + +function notifyFailure(message, error) { + const text = message || "NWC settings storage error."; + console.warn(`[nwcSettings] ${text}`, error); + emitToast(text, { variant: "error", error }); +} + +function notifyWarning(message, error) { + const text = message || "NWC settings storage warning."; + console.warn(`[nwcSettings] ${text}`, error); + emitToast(text, { variant: "warning", error }); +} + +async function readFromIndexedDb(dbKey) { + try { + const db = await openSettingsDb(); + if (!db) { + return null; + } + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(dbKey); + request.onsuccess = () => { + resolve(request.result || null); + }; + request.onerror = () => { + reject(request.error || new Error("Failed to load NWC settings.")); + }; + }); + } catch (error) { + notifyWarning("Unable to read wallet settings from IndexedDB.", error); + return null; + } +} + +function readFromLocalStorage(fallbackKey) { + try { + if (typeof localStorage === "undefined") { + return null; + } + const raw = localStorage.getItem(fallbackKey); + if (!raw) { + return null; + } + return JSON.parse(raw); + } catch (error) { + notifyFailure( + "Unable to read wallet settings from local storage fallback.", + error + ); + return null; + } +} + +async function writeToIndexedDb(dbKey, value) { + try { + const db = await openSettingsDb(); + if (!db) { + return false; + } + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.put(value, dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => { + reject(request.error || new Error("Failed to persist NWC settings.")); + }; + }); + return true; + } catch (error) { + notifyWarning("Unable to save wallet settings to IndexedDB.", error); + return false; + } +} + +function writeToLocalStorage(fallbackKey, value) { + try { + if (typeof localStorage === "undefined") { + return false; + } + localStorage.setItem(fallbackKey, JSON.stringify(value)); + return true; + } catch (error) { + notifyWarning("Unable to save wallet settings to local storage fallback.", error); + return false; + } +} + +async function deleteFromIndexedDb(dbKey) { + try { + const db = await openSettingsDb(); + if (!db) { + return false; + } + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => { + reject(request.error || new Error("Failed to clear NWC settings.")); + }; + }); + return true; + } catch (error) { + notifyWarning("Unable to remove wallet settings from IndexedDB.", error); + return false; + } +} + +function deleteFromLocalStorage(fallbackKey) { + try { + if (typeof localStorage === "undefined") { + return false; + } + localStorage.removeItem(fallbackKey); + return true; + } catch (error) { + notifyWarning("Unable to remove wallet settings from local storage fallback.", error); + return false; + } +} + +export function createDefaultNwcSettings() { + return createClone(DEFAULT_SETTINGS); +} + +export async function loadNwcSettings(pubkey) { + const { dbKey, fallbackKey } = resolveStorageKeys(pubkey); + const stored = await readFromIndexedDb(dbKey); + if (stored) { + return normalizeSettings(stored); + } + + const fallback = readFromLocalStorage(fallbackKey); + if (fallback) { + return normalizeSettings(fallback); + } + + return normalizeSettings(null); +} + +export async function saveNwcSettings(pubkey, partial = {}) { + const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey); + const existing = await readFromIndexedDb(dbKey); + const fallbackExisting = existing || readFromLocalStorage(fallbackKey); + const merged = mergeSettings(fallbackExisting || DEFAULT_SETTINGS, partial); + + const idbPersisted = await writeToIndexedDb(dbKey, merged); + const localPersisted = writeToLocalStorage(fallbackKey, merged); + + if (!idbPersisted && !localPersisted) { + notifyFailure( + "Failed to persist wallet settings. Recent changes were not saved.", + new Error(`Unable to save settings for ${normalized}`) + ); + throw new Error("Unable to persist NWC settings."); + } + + return merged; +} + +export async function clearNwcSettings(pubkey) { + const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey); + const idbCleared = await deleteFromIndexedDb(dbKey); + const localCleared = deleteFromLocalStorage(fallbackKey); + + if (!idbCleared && !localCleared) { + notifyFailure( + "Failed to clear wallet settings from browser storage.", + new Error(`Unable to clear settings for ${normalized}`) + ); + throw new Error("Unable to clear NWC settings."); + } + + return true; +}