From 014b1369b07d36298a8f37bf4092aea9ea8d315c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:04:28 -0400 Subject: [PATCH] feat: add async admin list adapters --- js/accessControl.js | 250 +++++++++++++++++------------- js/adminListStore.js | 357 +++++++++++++++++++++++++++++++++++++++++++ js/app.js | 219 ++++++++++++++++++++++---- js/channelProfile.js | 6 +- 4 files changed, 702 insertions(+), 130 deletions(-) create mode 100644 js/adminListStore.js diff --git a/js/accessControl.js b/js/accessControl.js index 6e7c40ee..b733572a 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -11,35 +11,13 @@ import { ADMIN_INITIAL_BLACKLIST, ADMIN_INITIAL_WHITELIST, } from "./lists.js"; - -const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; -const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; -const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; - -const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; -const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; - -function loadJSONList(key) { - try { - const raw = localStorage.getItem(key); - if (raw === null) { - return null; - } - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch (error) { - console.warn(`Failed to parse list for ${key}:`, error); - return null; - } -} - -function saveJSONList(key, values) { - try { - localStorage.setItem(key, JSON.stringify(values)); - } catch (error) { - console.warn(`Failed to persist list for ${key}:`, error); - } -} +import { + ADMIN_BLACKLIST_KEY, + ADMIN_EDITORS_KEY, + ADMIN_WHITELIST_KEY, + loadAdminState, + persistAdminState, +} from "./adminListStore.js"; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -57,26 +35,6 @@ function dedupeNpubs(values) { ); } -function migrateLegacyList(targetKey, legacyKey, fallback) { - const stored = loadJSONList(targetKey); - if (stored !== null) { - return dedupeNpubs(stored); - } - - const legacy = legacyKey ? loadJSONList(legacyKey) : null; - if (legacy !== null) { - const sanitized = dedupeNpubs(legacy); - saveJSONList(targetKey, sanitized); - return sanitized; - } - - const sanitizedFallback = dedupeNpubs(fallback); - if (sanitizedFallback.length) { - saveJSONList(targetKey, sanitizedFallback); - } - return sanitizedFallback; -} - function isValidNpub(npub) { if (typeof npub !== "string") { return false; @@ -100,30 +58,77 @@ class AccessControl { this.whitelist = new Set(); this.blacklist = new Set(); this.whitelistEnabled = getWhitelistMode(); - this.refresh(); + 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); + }); + } + + async _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]) + ); + this.whitelist = new Set( + dedupeNpubs([...ADMIN_INITIAL_WHITELIST, ...whitelist]) + ); + this.blacklist = new Set( + dedupeNpubs([...ADMIN_INITIAL_BLACKLIST, ...blacklist]) + ); + + 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; + } } refresh() { - this.editors = new Set( - dedupeNpubs([ - ...ADMIN_EDITORS_NPUBS, - ...migrateLegacyList(ADMIN_EDITORS_KEY, null, []), - ]) - ); + const promise = this._performRefresh(); + this._refreshPromise = promise; + promise.catch((error) => { + console.error("Failed to refresh admin lists:", error); + }); + return promise; + } - this.whitelist = new Set( - migrateLegacyList(ADMIN_WHITELIST_KEY, LEGACY_WHITELIST_KEY, [ - ...ADMIN_INITIAL_WHITELIST, - ]) - ); - - this.blacklist = new Set( - migrateLegacyList(ADMIN_BLACKLIST_KEY, LEGACY_BLACKLIST_KEY, [ - ...ADMIN_INITIAL_BLACKLIST, - ]) - ); - - this.whitelistEnabled = getWhitelistMode(); + async ensureReady() { + try { + await this._refreshPromise; + } catch (error) { + if (!this.hasLoaded) { + await this.refresh(); + await this._refreshPromise; + } else { + throw error; + } + } } whitelistMode() { @@ -162,7 +167,7 @@ class AccessControl { return Array.from(this.editors); } - addModerator(requestorNpub, moderatorNpub) { + async addModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -175,13 +180,18 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.editors.add(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + const nextEditors = dedupeNpubs([...this.getEditors(), normalized]); + + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeModerator(requestorNpub, moderatorNpub) { + async removeModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -190,14 +200,18 @@ class AccessControl { if (!normalized || normalized === ADMIN_SUPER_NPUB) { return { ok: false, error: "immutable" }; } + const nextEditors = this.getEditors().filter((value) => value !== normalized); - this.editors.delete(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToWhitelist(actorNpub, targetNpub) { + async addToWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -210,15 +224,24 @@ class AccessControl { return { ok: false, error: "invalid npub" }; } - this.blacklist.delete(normalized); - this.whitelist.add(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]); + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + whitelist: nextWhitelist, + blacklist: nextBlacklist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromWhitelist(actorNpub, targetNpub) { + async removeFromWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -227,14 +250,20 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); - this.whitelist.delete(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { whitelist: nextWhitelist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToBlacklist(actorNpub, targetNpub) { + async addToBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -251,15 +280,24 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.whitelist.delete(normalized); - this.blacklist.add(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]); + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + blacklist: nextBlacklist, + whitelist: nextWhitelist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromBlacklist(actorNpub, targetNpub) { + async removeFromBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -268,11 +306,17 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); - this.blacklist.delete(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { blacklist: nextBlacklist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } setWhitelistMode(actorNpub, enabled) { diff --git a/js/adminListStore.js b/js/adminListStore.js new file mode 100644 index 00000000..b4b0b183 --- /dev/null +++ b/js/adminListStore.js @@ -0,0 +1,357 @@ +// js/adminListStore.js + +import { + ADMIN_LIST_MODE, + ADMIN_LIST_NAMESPACE, + ADMIN_SUPER_NPUB, + ADMIN_EDITORS_NPUBS, + isDevMode, +} from "./config.js"; +import { + ADMIN_INITIAL_BLACKLIST, + ADMIN_INITIAL_WHITELIST, +} from "./lists.js"; +import { nostrClient } from "./nostr.js"; + +export const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; +export const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; +export const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; + +const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; +const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; + +const LIST_IDENTIFIERS = { + editors: "editors", + whitelist: "whitelist", + blacklist: "blacklist", +}; + +function createError(code, message, cause) { + const error = new Error(message); + error.code = code; + if (cause) { + error.cause = cause; + } + return error; +} + +function normalizeNpub(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function dedupeNpubs(values) { + const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; + return Array.from( + normalized.reduce((set, npub) => { + if (npub) { + set.add(npub); + } + return set; + }, new Set()) + ); +} + +function loadJSONList(key) { + if (typeof localStorage === "undefined") { + return null; + } + + try { + const raw = localStorage.getItem(key); + if (raw === null) { + return null; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch (error) { + console.warn(`Failed to parse list for ${key}:`, error); + return null; + } +} + +function saveJSONList(key, values) { + if (typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem(key, JSON.stringify(values)); + } catch (error) { + console.warn(`Failed to persist list for ${key}:`, error); + } +} + +function migrateLegacyList(targetKey, legacyKey, fallback) { + const stored = loadJSONList(targetKey); + if (stored !== null) { + return dedupeNpubs(stored); + } + + const legacy = legacyKey ? loadJSONList(legacyKey) : null; + if (legacy !== null) { + const sanitized = dedupeNpubs(legacy); + saveJSONList(targetKey, sanitized); + return sanitized; + } + + const sanitizedFallback = dedupeNpubs(fallback); + if (sanitizedFallback.length) { + saveJSONList(targetKey, sanitizedFallback); + } + return sanitizedFallback; +} + +async function loadLocalState() { + return { + editors: migrateLegacyList(ADMIN_EDITORS_KEY, null, ADMIN_EDITORS_NPUBS), + whitelist: migrateLegacyList( + ADMIN_WHITELIST_KEY, + LEGACY_WHITELIST_KEY, + ADMIN_INITIAL_WHITELIST + ), + blacklist: migrateLegacyList( + ADMIN_BLACKLIST_KEY, + LEGACY_BLACKLIST_KEY, + ADMIN_INITIAL_BLACKLIST + ), + }; +} + +async function persistLocalState(_actorNpub, updates = {}) { + if (updates.editors) { + saveJSONList(ADMIN_EDITORS_KEY, dedupeNpubs(updates.editors)); + } + if (updates.whitelist) { + saveJSONList(ADMIN_WHITELIST_KEY, dedupeNpubs(updates.whitelist)); + } + if (updates.blacklist) { + saveJSONList(ADMIN_BLACKLIST_KEY, dedupeNpubs(updates.blacklist)); + } +} + +function ensureNostrReady() { + if (!nostrClient || !nostrClient.pool) { + throw createError( + "nostr-unavailable", + "Nostr relay pool is not initialized." + ); + } + + const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : []; + if (!relays.length) { + throw createError("nostr-unavailable", "No Nostr relays configured."); + } + + return relays; +} + +function decodeNpubToHex(npub) { + const trimmed = normalizeNpub(npub); + if (!trimmed) { + throw createError("invalid npub", "Empty npub provided."); + } + + try { + const decoded = window?.NostrTools?.nip19?.decode(trimmed); + if (decoded?.type !== "npub" || !decoded?.data) { + throw new Error("Invalid npub format"); + } + return typeof decoded.data === "string" + ? decoded.data + : decoded.data?.pubkey || ""; + } catch (error) { + throw createError("invalid npub", "Unable to decode npub.", error); + } +} + +function encodeHexToNpub(hex) { + try { + return window?.NostrTools?.nip19?.npubEncode(hex) || ""; + } catch (error) { + if (isDevMode) { + console.warn("Failed to encode hex pubkey to npub:", error); + } + return ""; + } +} + +function extractNpubsFromEvent(event) { + if (!event || !Array.isArray(event.tags)) { + return []; + } + + const hexKeys = event.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + + const npubs = hexKeys + .map((hex) => encodeHexToNpub(hex)) + .filter((npub) => typeof npub === "string" && npub); + + return dedupeNpubs(npubs); +} + +async function loadNostrList(identifier) { + const relays = ensureNostrReady(); + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + const filter = { + kinds: [30000], + "#d": [dTagValue], + limit: 1, + }; + + const perRelay = await Promise.all( + relays.map(async (url) => { + try { + const events = await nostrClient.pool.list([url], [filter]); + return Array.isArray(events) ? events : []; + } catch (error) { + if (isDevMode) { + console.warn(`[adminListStore] Relay fetch failed for ${url}:`, error); + } + return []; + } + }) + ); + + const events = perRelay.flat(); + if (!events.length) { + return []; + } + + const newest = events.reduce((latest, event) => { + if (!latest) { + return event; + } + if (event.created_at === latest.created_at) { + return event.id > latest.id ? event : latest; + } + return event.created_at > latest.created_at ? event : latest; + }, null); + + return extractNpubsFromEvent(newest); +} + +async function loadNostrState() { + const editors = await loadNostrList(LIST_IDENTIFIERS.editors); + const whitelist = await loadNostrList(LIST_IDENTIFIERS.whitelist); + const blacklist = await loadNostrList(LIST_IDENTIFIERS.blacklist); + + return { + editors, + whitelist, + blacklist, + }; +} + +async function persistNostrState(actorNpub, updates = {}) { + const relays = ensureNostrReady(); + const extension = window?.nostr; + if (!extension || typeof extension.signEvent !== "function") { + throw createError( + "nostr-extension-missing", + "A Nostr extension with signEvent support is required." + ); + } + + const actorHex = decodeNpubToHex(actorNpub); + if (!actorHex) { + throw createError("invalid npub", "Unable to decode the actor npub."); + } + + const entries = Object.entries(updates).filter(([key, value]) => { + return key in LIST_IDENTIFIERS && Array.isArray(value); + }); + + if (!entries.length) { + return; + } + + const now = Math.floor(Date.now() / 1000); + + for (const [listKey, npubs] of entries) { + const identifier = LIST_IDENTIFIERS[listKey]; + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + + const hexPubkeys = Array.from( + new Set( + npubs.map((npub) => decodeNpubToHex(npub)).filter((hex) => !!hex) + ) + ); + + const tags = [["d", dTagValue]]; + hexPubkeys.forEach((hex) => { + tags.push(["p", hex]); + }); + + // Explicitly keep the super admin on the whitelist and editors lists when publishing. + if (listKey === "whitelist" || listKey === "editors") { + try { + const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB); + if (superHex && !hexPubkeys.includes(superHex)) { + tags.push(["p", superHex]); + } + } catch (error) { + if (isDevMode) { + console.warn("Failed to ensure super admin presence:", error); + } + } + } + + const event = { + kind: 30000, + pubkey: actorHex, + created_at: now, + tags, + content: "", + }; + + let signedEvent; + try { + signedEvent = await extension.signEvent(event); + } catch (error) { + throw createError("signature-failed", "Failed to sign admin list event.", error); + } + + const publishResults = await Promise.all( + relays.map(async (url) => { + try { + await nostrClient.pool.publish([url], signedEvent); + if (isDevMode) { + console.log(`[adminListStore] Published ${listKey} list to ${url}`); + } + return { url, success: true }; + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Failed to publish ${listKey} list to ${url}:`, + error + ); + } + return { url, success: false, error }; + } + }) + ); + + if (!publishResults.some((result) => result.success)) { + throw createError( + "publish-failed", + `Failed to publish ${listKey} list to any relay.` + ); + } + } +} + +export async function loadAdminState() { + if (ADMIN_LIST_MODE === "nostr") { + return loadNostrState(); + } + return loadLocalState(); +} + +export async function persistAdminState(actorNpub, updates) { + if (ADMIN_LIST_MODE === "nostr") { + return persistNostrState(actorNpub, updates); + } + return persistLocalState(actorNpub, updates); +} diff --git a/js/app.js b/js/app.js index b12d9236..17cc4a13 100644 --- a/js/app.js +++ b/js/app.js @@ -900,6 +900,12 @@ class bitvidApp { // 4. Connect to Nostr await nostrClient.init(); + try { + await accessControl.refresh(); + } catch (error) { + console.warn("Failed to refresh admin lists after connecting to Nostr:", error); + } + // Grab the "Subscriptions" link by its id in the sidebar this.subscriptionsLink = document.getElementById("subscriptionsLink"); @@ -3467,7 +3473,7 @@ class bitvidApp { this.selectProfilePane("account"); this.populateProfileRelays(); this.populateBlockedList(); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); console.log("Profile modal initialization successful"); return true; @@ -3551,12 +3557,16 @@ class bitvidApp { }); } - openProfileModal() { + async openProfileModal() { if (!this.profileModal) { return; } - this.refreshAdminPaneState(); + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane while opening profile modal:", error); + } this.selectProfilePane("account"); this.populateProfileRelays(); @@ -3821,11 +3831,18 @@ class bitvidApp { }); } - refreshAdminPaneState() { - accessControl.refresh(); + async refreshAdminPaneState() { const adminNav = this.profileNavButtons.admin; const adminPane = this.profilePaneElements.admin; + let loadError = null; + this.setAdminLoading(true); + try { + await accessControl.ensureReady(); + } catch (error) { + loadError = error; + } + const actorNpub = this.getCurrentUserNpub(); const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub); const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub); @@ -3842,6 +3859,18 @@ class bitvidApp { adminPane.setAttribute("aria-hidden", (!canEdit).toString()); } + if (loadError) { + console.error("Failed to load admin lists:", loadError); + const message = + loadError?.code === "nostr-unavailable" + ? "Unable to reach Nostr relays. Moderation lists may be out of date." + : "Unable to load moderation lists. Please try again."; + this.showError(message); + this.clearAdminLists(); + this.setAdminLoading(false); + return; + } + if (!canEdit) { this.clearAdminLists(); if (typeof actorNpub !== "string" || !actorNpub) { @@ -3850,6 +3879,7 @@ class bitvidApp { if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) { this.selectProfilePane("account"); } + this.setAdminLoading(false); return; } @@ -3865,9 +3895,49 @@ class bitvidApp { } this.populateAdminLists(); + this.setAdminLoading(false); + } + + storeAdminEmptyMessages() { + const capture = (element) => { + if (element instanceof HTMLElement && !element.dataset.defaultMessage) { + element.dataset.defaultMessage = element.textContent || ""; + } + }; + + capture(this.adminModeratorsEmpty); + capture(this.adminWhitelistEmpty); + capture(this.adminBlacklistEmpty); + } + + setAdminLoading(isLoading) { + this.storeAdminEmptyMessages(); + if (this.profilePaneElements.admin instanceof HTMLElement) { + this.profilePaneElements.admin.setAttribute( + "aria-busy", + isLoading ? "true" : "false" + ); + } + + const toggleMessage = (element, message) => { + if (!(element instanceof HTMLElement)) { + return; + } + if (isLoading) { + element.textContent = message; + element.classList.remove("hidden"); + } else { + element.textContent = element.dataset.defaultMessage || element.textContent; + } + }; + + toggleMessage(this.adminModeratorsEmpty, "Loading moderators…"); + toggleMessage(this.adminWhitelistEmpty, "Loading whitelist…"); + toggleMessage(this.adminBlacklistEmpty, "Loading blacklist…"); } clearAdminLists() { + this.storeAdminEmptyMessages(); if (this.adminModeratorList) { this.adminModeratorList.innerHTML = ""; } @@ -3878,12 +3948,21 @@ class bitvidApp { this.adminBlacklistList.innerHTML = ""; } if (this.adminModeratorsEmpty instanceof HTMLElement) { + this.adminModeratorsEmpty.textContent = + this.adminModeratorsEmpty.dataset.defaultMessage || + this.adminModeratorsEmpty.textContent; this.adminModeratorsEmpty.classList.remove("hidden"); } if (this.adminWhitelistEmpty instanceof HTMLElement) { + this.adminWhitelistEmpty.textContent = + this.adminWhitelistEmpty.dataset.defaultMessage || + this.adminWhitelistEmpty.textContent; this.adminWhitelistEmpty.classList.remove("hidden"); } if (this.adminBlacklistEmpty instanceof HTMLElement) { + this.adminBlacklistEmpty.textContent = + this.adminBlacklistEmpty.dataset.defaultMessage || + this.adminBlacklistEmpty.textContent; this.adminBlacklistEmpty.classList.remove("hidden"); } if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { @@ -4018,7 +4097,20 @@ class bitvidApp { return actorNpub; } - handleAddModerator() { + async handleAddModerator() { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before adding moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub || !this.adminModeratorInput) { return; @@ -4036,7 +4128,7 @@ class bitvidApp { } try { - const result = accessControl.addModerator(actorNpub, value); + const result = await accessControl.addModerator(actorNpub, value); if (!result.ok) { this.showError(this.describeAdminError(result.error)); return; @@ -4044,7 +4136,7 @@ class bitvidApp { this.adminModeratorInput.value = ""; this.showSuccess("Moderator added successfully."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } finally { if (this.adminAddModeratorBtn) { this.adminAddModeratorBtn.disabled = false; @@ -4053,7 +4145,24 @@ class bitvidApp { } } - handleRemoveModerator(npub, button) { + async handleRemoveModerator(npub, button) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before removing moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (button instanceof HTMLElement) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub) { if (button instanceof HTMLElement) { @@ -4063,7 +4172,7 @@ class bitvidApp { return; } - const result = accessControl.removeModerator(actorNpub, npub); + const result = await accessControl.removeModerator(actorNpub, npub); if (!result.ok) { this.showError(this.describeAdminError(result.error)); if (button instanceof HTMLElement) { @@ -4074,10 +4183,27 @@ class bitvidApp { } this.showSuccess("Moderator removed."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } - handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before updating entries:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (sourceButton instanceof HTMLElement) { + sourceButton.disabled = false; + sourceButton.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(false); if (!actorNpub) { if (sourceButton instanceof HTMLElement) { @@ -4115,12 +4241,12 @@ class bitvidApp { let result; if (isWhitelist) { result = isAdd - ? accessControl.addToWhitelist(actorNpub, target) - : accessControl.removeFromWhitelist(actorNpub, target); + ? await accessControl.addToWhitelist(actorNpub, target) + : await accessControl.removeFromWhitelist(actorNpub, target); } else { result = isAdd - ? accessControl.addToBlacklist(actorNpub, target) - : accessControl.removeFromBlacklist(actorNpub, target); + ? await accessControl.addToBlacklist(actorNpub, target) + : await accessControl.removeFromBlacklist(actorNpub, target); } if (!result.ok) { @@ -4144,7 +4270,7 @@ class bitvidApp { ? "Added to the blacklist." : "Removed from the blacklist."; this.showSuccess(successMessage); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); if (buttonToToggle instanceof HTMLElement) { buttonToToggle.disabled = false; @@ -4160,12 +4286,38 @@ class bitvidApp { return "That account cannot be modified."; case "forbidden": return "You do not have permission to perform that action."; + case "nostr-unavailable": + return "Unable to reach the configured Nostr relays. Please retry once your connection is restored."; + case "nostr-extension-missing": + return "Connect a Nostr extension before editing moderation lists."; + case "signature-failed": + return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again."; + case "publish-failed": + return "Failed to publish the update to Nostr relays. Please try again."; + case "storage-error": + return "Unable to update moderation settings. Please try again."; default: return "Unable to update moderation settings. Please try again."; } } - handleWhitelistModeToggle(enabled) { + async handleWhitelistModeToggle(enabled) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before toggling whitelist mode:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { + this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); + } + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub) { if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { @@ -4188,11 +4340,16 @@ class bitvidApp { ? "Whitelist mode enabled. Only approved creators will appear in feeds." : "Whitelist mode disabled. All creators except those blacklisted will appear." ); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } - onAccessControlUpdated() { - this.refreshAdminPaneState(); + async onAccessControlUpdated() { + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane after update:", error); + } + this.loadVideos(true).catch((error) => { console.error("Failed to refresh videos after admin update:", error); }); @@ -4213,7 +4370,9 @@ class bitvidApp { // 2) Profile button if (this.profileButton) { this.profileButton.addEventListener("click", () => { - this.openProfileModal(); + this.openProfileModal().catch((error) => { + console.error("Failed to open profile modal:", error); + }); }); } @@ -4823,7 +4982,7 @@ class bitvidApp { return; } - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Hide login button if present if (this.loginButton) { @@ -4907,7 +5066,7 @@ class bitvidApp { // Clear localStorage localStorage.removeItem("userPubKey"); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Refresh the video list so user sees only public videos again await this.loadVideos(); @@ -5346,7 +5505,11 @@ class bitvidApp { async loadVideos(forceFetch = false) { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before fetching videos:", error); + } const shouldIncludeVideo = (video) => { if (!video || typeof video !== "object") { @@ -7649,7 +7812,11 @@ class bitvidApp { return; } - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before playback:", error); + } const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (!accessControl.canAccess(authorNpub)) { if (accessControl.isBlacklisted(authorNpub)) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 51719d31..e448f3dd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -251,7 +251,11 @@ async function loadUserProfile(pubkey) { */ async function loadUserVideos(pubkey) { try { - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before channel fetch:", error); + } // 1) Build filter for videos from this pubkey const filter = {