mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
Merge pull request #292 from PR0M3TH3AN/codex/implement-nwcsettings.js-with-storage-features
Add NWC settings persistence with per-profile isolation
This commit is contained in:
2 files changed
+533
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user