From fd6919b65ff69f4692f3eec4f8f3768e0a92603b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:29:59 -0400 Subject: [PATCH] Ensure watch history decrypts request nip04 permission --- js/app.js | 483 ++++------------------ js/nostr.js | 120 +++++- js/ui/moreMenuController.js | 598 ++++++++++++++++++++++++++++ tests/more-menu-controller.test.mjs | 161 ++++++++ tests/watch-history.test.mjs | 83 +++- 5 files changed, 1025 insertions(+), 420 deletions(-) create mode 100644 js/ui/moreMenuController.js create mode 100644 tests/more-menu-controller.test.mjs diff --git a/js/app.js b/js/app.js index c0be7a2d..43505d19 100644 --- a/js/app.js +++ b/js/app.js @@ -63,6 +63,7 @@ import { UploadModal } from "./ui/components/UploadModal.js"; import { EditModal } from "./ui/components/EditModal.js"; import { RevertModal } from "./ui/components/RevertModal.js"; import { VideoListView } from "./ui/views/VideoListView.js"; +import MoreMenuController from "./ui/moreMenuController.js"; import ProfileModalController from "./ui/profileModalController.js"; import ZapController from "./ui/zapController.js"; import { MediaLoader } from "./utils/mediaLoader.js"; @@ -182,9 +183,7 @@ class Application { this.videoListViewEditHandler = null; this.videoListViewRevertHandler = null; this.videoListViewDeleteHandler = null; - this.videoListViewBlacklistHandler = null; - this.boundVideoListShareListener = null; - this.boundVideoListContextListener = null; + this.moreMenuController = null; this.latestFeedMetadata = null; this.nostrService = services.nostrService || nostrService; @@ -619,6 +618,53 @@ class Application { this.boundVideoModalZapWalletHandler ); + this.moreMenuController = new MoreMenuController({ + document, + accessControl, + userBlocks, + subscriptions, + clipboard: + typeof navigator !== "undefined" && navigator?.clipboard + ? navigator.clipboard + : null, + isDevMode, + callbacks: { + getCurrentVideo: () => this.currentVideo, + getCurrentUserNpub: () => this.getCurrentUserNpub(), + getCurrentUserPubkey: () => this.pubkey, + canCurrentUserManageBlacklist: () => + this.canCurrentUserManageBlacklist(), + openCreatorChannel: () => this.openCreatorChannel(), + goToProfile: (author) => this.goToProfile(author), + showError: (message) => this.showError(message), + showSuccess: (message) => this.showSuccess(message), + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), + buildShareUrlFromEventId: (eventId) => + this.buildShareUrlFromEventId(eventId), + handleRemoveHistoryAction: (payload) => + this.handleRemoveHistoryAction(payload), + handleRepostAction: (payload) => this.handleRepostAction(payload), + handleMirrorAction: (payload) => this.handleMirrorAction(payload), + handleEnsurePresenceAction: (payload) => + this.handleEnsurePresenceAction(payload), + loadVideos: () => this.loadVideos(), + onUserBlocksUpdated: () => { + if (this.profileController) { + try { + this.profileController.populateBlockedList(); + } catch (error) { + console.warn( + "[profileModal] Failed to refresh blocked list after update:", + error, + ); + } + } + }, + }, + }); + this.moreMenuController.setVideoModal(this.videoModal); + // Hide/Show Subscriptions Link this.subscriptionsLink = null; @@ -647,9 +693,6 @@ class Application { this.videoSubscription = subscription || null; } ); - this.moreMenuGlobalHandlerBound = false; - this.boundMoreMenuDocumentClick = null; - this.boundMoreMenuDocumentKeydown = null; this.boundNwcSettingsToastHandler = null; Object.defineProperty(this, "savedProfiles", { configurable: false, @@ -812,41 +855,9 @@ class Application { }; this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler); - this.videoListViewBlacklistHandler = ({ video, dataset }) => { - const detail = { - ...(dataset || {}), - author: dataset?.author || video?.pubkey || "", - }; - this.handleMoreMenuAction("blacklist-author", detail); - }; - this.videoListView.setBlacklistHandler(this.videoListViewBlacklistHandler); - - this.boundVideoListShareListener = (event) => { - const detail = event?.detail || {}; - const dataset = { - ...(detail.dataset || {}), - eventId: detail.eventId || detail.dataset?.eventId || detail.video?.id || "", - context: detail.dataset?.context || "card", - }; - this.handleMoreMenuAction(detail.action || "copy-link", dataset); - }; - this.videoListView.addEventListener( - "video:share", - this.boundVideoListShareListener - ); - - this.boundVideoListContextListener = (event) => { - const detail = event?.detail || {}; - const dataset = { - ...(detail.dataset || {}), - eventId: detail.dataset?.eventId || detail.video?.id || "", - }; - this.handleMoreMenuAction(detail.action, dataset); - }; - this.videoListView.addEventListener( - "video:context-action", - this.boundVideoListContextListener - ); + if (this.moreMenuController) { + this.moreMenuController.attachVideoListView(this.videoListView); + } // NEW: reference to the login modal's close button this.closeLoginModalBtn = @@ -4379,127 +4390,27 @@ class Application { } ensureGlobalMoreMenuHandlers() { - if (this.moreMenuGlobalHandlerBound) { - return; + if (this.moreMenuController) { + this.moreMenuController.ensureGlobalMoreMenuHandlers(); } - - this.moreMenuGlobalHandlerBound = true; - - this.boundMoreMenuDocumentClick = (event) => { - const target = event.target; - if ( - target instanceof HTMLElement && - (target.closest("[data-more-menu-wrapper]") || - target.closest("[data-more-menu]")) - ) { - return; - } - this.closeAllMoreMenus(); - }; - - this.boundMoreMenuDocumentKeydown = (event) => { - if (event.key === "Escape") { - this.closeAllMoreMenus(); - } - }; - - document.addEventListener("click", this.boundMoreMenuDocumentClick); - document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown); } closeAllMoreMenus() { - if (this.videoListView) { - this.videoListView.closeAllMenus(); + if (this.moreMenuController) { + this.moreMenuController.closeAllMoreMenus(); } - - const menus = document.querySelectorAll("[data-more-menu]"); - menus.forEach((menu) => { - if (menu instanceof HTMLElement) { - menu.classList.add("hidden"); - } - }); - - const buttons = document.querySelectorAll("[data-more-dropdown]"); - buttons.forEach((btn) => { - if (btn instanceof HTMLElement) { - btn.setAttribute("aria-expanded", "false"); - } - }); } attachMoreMenuHandlers(container) { - if (!container || typeof container.querySelectorAll !== "function") { - return; + if (this.moreMenuController) { + this.moreMenuController.attachMoreMenuHandlers(container); } - - const buttons = container.querySelectorAll("[data-more-dropdown]"); - if (!buttons.length) { - return; - } - - this.ensureGlobalMoreMenuHandlers(); - - buttons.forEach((button) => { - if (!(button instanceof HTMLElement)) { - return; - } - if (button.dataset.moreMenuToggleBound === "true") { - return; - } - button.dataset.moreMenuToggleBound = "true"; - button.setAttribute("aria-expanded", "false"); - - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - const key = button.getAttribute("data-more-dropdown") || ""; - const dropdown = document.getElementById(`moreDropdown-${key}`); - if (!(dropdown instanceof HTMLElement)) { - return; - } - - const willOpen = dropdown.classList.contains("hidden"); - this.closeAllMoreMenus(); - - if (willOpen) { - dropdown.classList.remove("hidden"); - button.setAttribute("aria-expanded", "true"); - } - }); - }); - - const actionButtons = container.querySelectorAll( - "[data-more-menu] button[data-action]" - ); - actionButtons.forEach((button) => { - if (!(button instanceof HTMLElement)) { - return; - } - if (button.dataset.moreMenuActionBound === "true") { - return; - } - button.dataset.moreMenuActionBound = "true"; - - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - const { action } = button.dataset; - this.handleMoreMenuAction(action, button.dataset); - this.closeAllMoreMenus(); - }); - }); } syncModalMoreMenuData() { - if (!this.videoModal) { - return; + if (this.moreMenuController) { + this.moreMenuController.syncModalMoreMenuData(); } - - this.videoModal.syncMoreMenuData({ - currentVideo: this.currentVideo, - canManageBlacklist: this.canCurrentUserManageBlacklist(), - }); } derivePointerFromDataset(dataset = {}, context = "") { @@ -4534,253 +4445,11 @@ class Application { } async handleMoreMenuAction(action, dataset = {}) { - const normalized = typeof action === "string" ? action.trim() : ""; - const context = dataset.context || ""; - - switch (normalized) { - case "open-channel": { - if (context === "modal") { - this.openCreatorChannel(); - break; - } - - const author = - dataset.author || (this.currentVideo ? this.currentVideo.pubkey : ""); - if (author) { - this.goToProfile(author); - } else { - this.showError("No creator info available."); - } - break; - } - case "copy-link": { - const eventId = - dataset.eventId || - (context === "modal" && this.currentVideo ? this.currentVideo.id : ""); - if (!eventId) { - this.showError("Could not generate link."); - break; - } - const shareUrl = this.buildShareUrlFromEventId(eventId); - if (!shareUrl) { - this.showError("Could not generate link."); - break; - } - navigator.clipboard - .writeText(shareUrl) - .then(() => this.showSuccess("Video link copied to clipboard!")) - .catch(() => this.showError("Failed to copy the link.")); - break; - } - case "remove-history": { - await this.handleRemoveHistoryAction(dataset); - break; - } - case "repost-event": { - await this.handleRepostAction(dataset); - break; - } - case "mirror-video": { - await this.handleMirrorAction(dataset); - break; - } - case "ensure-presence": { - await this.handleEnsurePresenceAction(dataset); - break; - } - case "copy-npub": { - const explicitNpub = - typeof dataset.npub === "string" && dataset.npub.trim() - ? dataset.npub.trim() - : ""; - const authorCandidate = dataset.author || ""; - const fallbackNpub = this.safeEncodeNpub(authorCandidate); - const valueToCopy = explicitNpub || fallbackNpub; - - if (!valueToCopy) { - this.showError("No npub available to copy."); - break; - } - - try { - await navigator.clipboard.writeText(valueToCopy); - this.showSuccess("Channel npub copied to clipboard!"); - } catch (error) { - console.error("Failed to copy npub:", error); - this.showError("Failed to copy the npub."); - } - break; - } - case "blacklist-author": { - const actorNpub = this.getCurrentUserNpub(); - if (!actorNpub) { - this.showError("Please login as a moderator to manage the blacklist."); - break; - } - - try { - await accessControl.ensureReady(); - } catch (error) { - console.warn("Failed to refresh moderation state before blacklisting:", error); - } - - if (!accessControl.canEditAdminLists(actorNpub)) { - this.showError("Only moderators can manage the blacklist."); - break; - } - - let author = dataset.author || ""; - if (!author && context === "modal" && this.currentVideo?.pubkey) { - author = this.currentVideo.pubkey; - } - if (!author && this.currentVideo?.pubkey) { - author = this.currentVideo.pubkey; - } - - const explicitNpub = - typeof dataset.npub === "string" && dataset.npub.trim() - ? dataset.npub.trim() - : ""; - const targetNpub = explicitNpub || this.safeEncodeNpub(author); - - if (!targetNpub) { - this.showError("Unable to determine the creator npub."); - break; - } - - try { - const result = await accessControl.addToBlacklist( - actorNpub, - targetNpub - ); - - if (result?.ok) { - this.showSuccess("Creator added to the blacklist."); - subscriptions - .refreshActiveFeed({ reason: "admin-blacklist-update" }) - .catch((error) => { - if (isDevMode) { - console.warn( - "[Subscriptions] Failed to refresh after blacklist update:", - error - ); - } - }); - } else { - const code = result?.error || "unknown"; - switch (code) { - case "self": - this.showError("You cannot blacklist yourself."); - break; - case "immutable": - this.showError( - "Moderators cannot blacklist the super admin or fellow moderators." - ); - break; - case "invalid npub": - this.showError("Unable to blacklist this creator."); - break; - case "forbidden": - this.showError("Only moderators can manage the blacklist."); - break; - default: - this.showError( - "Failed to update the blacklist. Please try again." - ); - break; - } - } - } catch (error) { - console.error("Failed to add creator to blacklist:", error); - this.showError("Failed to update the blacklist. Please try again."); - } - break; - } - case "block-author": { - if (!this.pubkey) { - this.showError("Please login to manage your block list."); - break; - } - - const authorCandidate = - dataset.author || - (this.currentVideo && this.currentVideo.pubkey) || - ""; - - const trimmed = - typeof authorCandidate === "string" ? authorCandidate.trim() : ""; - if (!trimmed) { - this.showError("Unable to determine the creator to block."); - break; - } - - let normalizedHex = ""; - if (trimmed.startsWith("npub1")) { - normalizedHex = this.safeDecodeNpub(trimmed) || ""; - } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { - normalizedHex = trimmed.toLowerCase(); - } - - if (!normalizedHex) { - this.showError("Unable to determine the creator to block."); - break; - } - - if (normalizedHex === this.pubkey) { - this.showError("You cannot block yourself."); - break; - } - - try { - await userBlocks.ensureLoaded(this.pubkey); - - if (userBlocks.isBlocked(normalizedHex)) { - this.showSuccess("You already blocked this creator."); - } else { - await userBlocks.addBlock(normalizedHex, this.pubkey); - this.showSuccess( - "Creator blocked. You won't see their videos anymore." - ); - } - - if (this.profileController) { - try { - this.profileController.populateBlockedList(); - } catch (error) { - console.warn( - "[profileModal] Failed to refresh blocked list after update:", - error, - ); - } - } - await this.loadVideos(); - subscriptions - .refreshActiveFeed({ reason: "user-block-update" }) - .catch((error) => { - if (isDevMode) { - console.warn( - "[Subscriptions] Failed to refresh after user block update:", - error - ); - } - }); - } catch (error) { - console.error("Failed to update personal block list:", error); - const message = - error?.code === "nip04-missing" - ? "Your Nostr extension must support NIP-04 to manage private lists." - : "Failed to update your block list. Please try again."; - this.showError(message); - } - break; - } - case "report": { - this.showSuccess("Reporting coming soon."); - break; - } - default: - break; + if (!this.moreMenuController) { + return; } + + return this.moreMenuController.handleMoreMenuAction(action, dataset); } async handleRepostAction(dataset = {}) { @@ -6981,35 +6650,29 @@ class Application { } if (this.videoListView) { - if (this.boundVideoListShareListener) { - this.videoListView.removeEventListener( - "video:share", - this.boundVideoListShareListener - ); - this.boundVideoListShareListener = null; - } - if (this.boundVideoListContextListener) { - this.videoListView.removeEventListener( - "video:context-action", - this.boundVideoListContextListener - ); - this.boundVideoListContextListener = null; + if (this.moreMenuController) { + this.moreMenuController.detachVideoListView(); } this.videoListView.setPlaybackHandler(null); this.videoListView.setEditHandler(null); this.videoListView.setRevertHandler(null); this.videoListView.setDeleteHandler(null); - this.videoListView.setBlacklistHandler(null); this.videoListViewPlaybackHandler = null; this.videoListViewEditHandler = null; this.videoListViewRevertHandler = null; this.videoListViewDeleteHandler = null; - this.videoListViewBlacklistHandler = null; try { this.videoListView.destroy(); } catch (error) { console.warn("[Application] Failed to destroy VideoListView:", error); } + this.videoListView = null; + } + + if (this.moreMenuController) { + this.moreMenuController.setVideoModal(null); + this.moreMenuController.destroy(); + this.moreMenuController = null; } if (this.profileController) { diff --git a/js/nostr.js b/js/nostr.js index f9e92491..f93ba777 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -72,6 +72,15 @@ 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 DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ + "get_public_key", + "sign_event", + "nip04.encrypt", + "nip04.decrypt", + "read_relays", + "write_relays", +]); + const viewEventPublishMemory = new Map(); const rebroadcastAttemptMemory = new Map(); @@ -2886,6 +2895,99 @@ export class NostrClient { this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); + this.extensionPermissionCache = new Set(); + } + + markExtensionPermissions(methods = []) { + if (!this.extensionPermissionCache) { + this.extensionPermissionCache = new Set(); + } + + if (!Array.isArray(methods)) { + return; + } + + for (const method of methods) { + if (typeof method !== "string") { + continue; + } + const normalized = method.trim(); + if (normalized) { + this.extensionPermissionCache.add(normalized); + } + } + } + + async ensureExtensionPermissions(methods = DEFAULT_NIP07_PERMISSION_METHODS) { + if (!Array.isArray(methods)) { + methods = []; + } + + const normalized = Array.from( + new Set( + methods + .map((method) => + typeof method === "string" && method.trim() ? method.trim() : "", + ) + .filter(Boolean), + ), + ); + + if (!normalized.length) { + return { ok: true }; + } + + const outstanding = normalized.filter((method) => + this.extensionPermissionCache ? !this.extensionPermissionCache.has(method) : true, + ); + + if (!outstanding.length) { + return { ok: true }; + } + + const extension = typeof window !== "undefined" ? window.nostr : null; + if (!extension) { + return { ok: false, error: new Error("extension-unavailable") }; + } + + if (typeof extension.enable !== "function") { + this.markExtensionPermissions(outstanding); + return { ok: true, code: "enable-unavailable" }; + } + + const permissionVariants = []; + if (outstanding.length) { + permissionVariants.push({ + permissions: outstanding.map((method) => ({ method })), + }); + permissionVariants.push({ permissions: outstanding }); + } + permissionVariants.push(null); + + let lastError = null; + for (const options of permissionVariants) { + try { + await runNip07WithRetry( + () => (options ? extension.enable(options) : extension.enable()), + { label: "extension.enable" }, + ); + this.markExtensionPermissions(outstanding); + return { ok: true }; + } catch (error) { + lastError = error; + if (options && isDevMode) { + console.warn( + "[nostr] extension.enable request with explicit permissions failed:", + error, + ); + } + } + } + + return { + ok: false, + error: lastError || new Error("permission-denied"), + }; } applyRootCreatedAt(video) { @@ -3813,6 +3915,10 @@ export class NostrClient { } } + if (useExtensionEncrypt) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + } + const canonicalItems = canonicalizeWatchHistoryItems( Array.isArray(rawItems) ? rawItems : [], WATCH_HISTORY_MAX_ITEMS, @@ -4590,6 +4696,7 @@ export class NostrClient { typeof extension?.nip04?.decrypt === "function" && normalizeActorKey(this.pubkey) === actorKey; if (extensionDecrypt) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); return extension.nip04.decrypt(resolvedActor, ciphertext); } if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { @@ -5471,14 +5578,9 @@ export class NostrClient { if (isDevMode) { console.log("Requesting permissions from NIP-07 extension..."); } - const requestedPermissionMethods = [ - "get_public_key", - "sign_event", - "nip04.encrypt", - "nip04.decrypt", - "read_relays", - "write_relays", - ]; + const requestedPermissionMethods = Array.from( + DEFAULT_NIP07_PERMISSION_METHODS, + ); const permissionVariants = []; const objectPermissions = requestedPermissionMethods @@ -5529,6 +5631,8 @@ export class NostrClient { : "The NIP-07 extension denied the permission request.", ); } + + this.markExtensionPermissions(requestedPermissionMethods); } if (allowAccountSelection && typeof extension.selectAccounts === "function") { diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js new file mode 100644 index 00000000..aac09fe8 --- /dev/null +++ b/js/ui/moreMenuController.js @@ -0,0 +1,598 @@ +export default class MoreMenuController { + constructor(options = {}) { + const { + document: doc = typeof document !== "undefined" ? document : null, + accessControl = null, + userBlocks = null, + subscriptions = null, + clipboard = typeof navigator !== "undefined" ? navigator.clipboard : null, + isDevMode = false, + callbacks = {}, + } = options; + + this.document = doc; + this.accessControl = accessControl; + this.userBlocks = userBlocks; + this.subscriptions = subscriptions; + this.clipboard = clipboard; + this.isDevMode = Boolean(isDevMode); + + this.callbacks = { + getCurrentVideo: callbacks.getCurrentVideo || (() => null), + getCurrentUserNpub: callbacks.getCurrentUserNpub || (() => null), + getCurrentUserPubkey: callbacks.getCurrentUserPubkey || (() => null), + canCurrentUserManageBlacklist: + callbacks.canCurrentUserManageBlacklist || (() => false), + openCreatorChannel: callbacks.openCreatorChannel || (() => {}), + goToProfile: callbacks.goToProfile || (() => {}), + showError: callbacks.showError || (() => {}), + showSuccess: callbacks.showSuccess || (() => {}), + safeEncodeNpub: callbacks.safeEncodeNpub || (() => ""), + safeDecodeNpub: callbacks.safeDecodeNpub || (() => ""), + buildShareUrlFromEventId: + callbacks.buildShareUrlFromEventId || (() => ""), + handleRemoveHistoryAction: + callbacks.handleRemoveHistoryAction || (() => Promise.resolve()), + handleRepostAction: + callbacks.handleRepostAction || (() => Promise.resolve()), + handleMirrorAction: + callbacks.handleMirrorAction || (() => Promise.resolve()), + handleEnsurePresenceAction: + callbacks.handleEnsurePresenceAction || (() => Promise.resolve()), + loadVideos: callbacks.loadVideos || (() => Promise.resolve()), + onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}), + }; + + this.moreMenuGlobalHandlerBound = false; + this.boundDocumentClick = null; + this.boundDocumentKeydown = null; + + this.videoListView = null; + this.videoModal = null; + + this.boundVideoListShareListener = null; + this.boundVideoListContextListener = null; + this.boundVideoListBlacklistHandler = null; + } + + setVideoModal(videoModal) { + this.videoModal = videoModal || null; + } + + attachVideoListView(view) { + if (view === this.videoListView) { + return; + } + + this.detachVideoListView(); + + if (!view) { + return; + } + + this.videoListView = view; + + this.boundVideoListBlacklistHandler = ({ video, dataset }) => { + const detail = { + ...(dataset || {}), + author: dataset?.author || video?.pubkey || "", + }; + this.handleMoreMenuAction("blacklist-author", detail); + }; + + if (typeof view.setBlacklistHandler === "function") { + view.setBlacklistHandler(this.boundVideoListBlacklistHandler); + } + + this.boundVideoListShareListener = (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: + detail.eventId || + detail.dataset?.eventId || + detail.video?.id || + "", + context: detail.dataset?.context || "card", + }; + const action = detail.action || "copy-link"; + this.handleMoreMenuAction(action, dataset); + }; + + if (typeof view.addEventListener === "function") { + view.addEventListener("video:share", this.boundVideoListShareListener); + } + + this.boundVideoListContextListener = (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: detail.dataset?.eventId || detail.video?.id || "", + }; + this.handleMoreMenuAction(detail.action, dataset); + }; + + if (typeof view.addEventListener === "function") { + view.addEventListener( + "video:context-action", + this.boundVideoListContextListener, + ); + } + } + + detachVideoListView() { + if (!this.videoListView) { + return; + } + + if ( + this.boundVideoListShareListener && + typeof this.videoListView.removeEventListener === "function" + ) { + this.videoListView.removeEventListener( + "video:share", + this.boundVideoListShareListener, + ); + } + + if ( + this.boundVideoListContextListener && + typeof this.videoListView.removeEventListener === "function" + ) { + this.videoListView.removeEventListener( + "video:context-action", + this.boundVideoListContextListener, + ); + } + + if (typeof this.videoListView.setBlacklistHandler === "function") { + this.videoListView.setBlacklistHandler(null); + } + + this.boundVideoListShareListener = null; + this.boundVideoListContextListener = null; + this.boundVideoListBlacklistHandler = null; + this.videoListView = null; + } + + destroy() { + this.detachVideoListView(); + this.setVideoModal(null); + + if (this.document && this.boundDocumentClick) { + this.document.removeEventListener("click", this.boundDocumentClick); + } + if (this.document && this.boundDocumentKeydown) { + this.document.removeEventListener("keydown", this.boundDocumentKeydown); + } + + this.boundDocumentClick = null; + this.boundDocumentKeydown = null; + this.moreMenuGlobalHandlerBound = false; + } + + ensureGlobalMoreMenuHandlers() { + if (this.moreMenuGlobalHandlerBound) { + return; + } + + if (!this.document) { + return; + } + + this.moreMenuGlobalHandlerBound = true; + + this.boundDocumentClick = (event) => { + const target = event?.target; + if (!target || typeof target.closest !== "function") { + this.closeAllMoreMenus(); + return; + } + if ( + target.closest("[data-more-menu-wrapper]") || + target.closest("[data-more-menu]") + ) { + return; + } + this.closeAllMoreMenus(); + }; + + this.boundDocumentKeydown = (event) => { + if (event?.key === "Escape") { + this.closeAllMoreMenus(); + } + }; + + this.document.addEventListener("click", this.boundDocumentClick); + this.document.addEventListener("keydown", this.boundDocumentKeydown); + } + + closeAllMoreMenus() { + if ( + this.videoListView && + typeof this.videoListView.closeAllMenus === "function" + ) { + this.videoListView.closeAllMenus(); + } + + if (!this.document || typeof this.document.querySelectorAll !== "function") { + return; + } + + const HTMLElementCtor = + this.document?.defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + const menus = this.document.querySelectorAll("[data-more-menu]"); + menus.forEach((menu) => { + if (!HTMLElementCtor || menu instanceof HTMLElementCtor) { + menu.classList.add("hidden"); + } + }); + + const buttons = this.document.querySelectorAll("[data-more-dropdown]"); + buttons.forEach((button) => { + if (!HTMLElementCtor || button instanceof HTMLElementCtor) { + button.setAttribute("aria-expanded", "false"); + } + }); + } + + attachMoreMenuHandlers(container) { + if (!container || typeof container.querySelectorAll !== "function") { + return; + } + + if (!this.document) { + return; + } + + const buttons = container.querySelectorAll("[data-more-dropdown]"); + if (!buttons.length) { + return; + } + + this.ensureGlobalMoreMenuHandlers(); + + const HTMLElementCtor = + this.document?.defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + + buttons.forEach((button) => { + if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) { + return; + } + if (button.dataset.moreMenuToggleBound === "true") { + return; + } + button.dataset.moreMenuToggleBound = "true"; + button.setAttribute("aria-expanded", "false"); + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + const key = button.getAttribute("data-more-dropdown") || ""; + const dropdown = this.document.getElementById(`moreDropdown-${key}`); + if (HTMLElementCtor && !(dropdown instanceof HTMLElementCtor)) { + return; + } + + const willOpen = dropdown.classList.contains("hidden"); + this.closeAllMoreMenus(); + + if (willOpen) { + dropdown.classList.remove("hidden"); + button.setAttribute("aria-expanded", "true"); + } + }); + }); + + const actionButtons = container.querySelectorAll( + "[data-more-menu] button[data-action]", + ); + actionButtons.forEach((button) => { + if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) { + return; + } + if (button.dataset.moreMenuActionBound === "true") { + return; + } + button.dataset.moreMenuActionBound = "true"; + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const { action } = button.dataset; + this.handleMoreMenuAction(action, button.dataset); + this.closeAllMoreMenus(); + }); + }); + } + + syncModalMoreMenuData() { + if (!this.videoModal || typeof this.videoModal.syncMoreMenuData !== "function") { + return; + } + + const currentVideo = this.callbacks.getCurrentVideo(); + const canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(); + + this.videoModal.syncMoreMenuData({ + currentVideo, + canManageBlacklist, + }); + } + + async handleMoreMenuAction(action, dataset = {}) { + const normalized = typeof action === "string" ? action.trim() : ""; + const context = dataset.context || ""; + const currentVideo = this.callbacks.getCurrentVideo(); + + switch (normalized) { + case "open-channel": { + if (context === "modal") { + this.callbacks.openCreatorChannel(); + break; + } + + const author = + dataset.author || (currentVideo ? currentVideo.pubkey : ""); + if (author) { + this.callbacks.goToProfile(author); + } else { + this.callbacks.showError("No creator info available."); + } + break; + } + case "copy-link": { + const eventId = + dataset.eventId || (context === "modal" ? currentVideo?.id : ""); + if (!eventId) { + this.callbacks.showError("Could not generate link."); + break; + } + const shareUrl = this.callbacks.buildShareUrlFromEventId(eventId); + if (!shareUrl) { + this.callbacks.showError("Could not generate link."); + break; + } + + if (!this.clipboard || typeof this.clipboard.writeText !== "function") { + this.callbacks.showError("Failed to copy the link."); + break; + } + + this.clipboard + .writeText(shareUrl) + .then(() => + this.callbacks.showSuccess("Video link copied to clipboard!"), + ) + .catch(() => this.callbacks.showError("Failed to copy the link.")); + break; + } + case "remove-history": { + await this.callbacks.handleRemoveHistoryAction(dataset); + break; + } + case "repost-event": { + await this.callbacks.handleRepostAction(dataset); + break; + } + case "mirror-video": { + await this.callbacks.handleMirrorAction(dataset); + break; + } + case "ensure-presence": { + await this.callbacks.handleEnsurePresenceAction(dataset); + break; + } + case "copy-npub": { + const explicitNpub = + typeof dataset.npub === "string" && dataset.npub.trim() + ? dataset.npub.trim() + : ""; + const authorCandidate = dataset.author || ""; + const fallbackNpub = this.callbacks.safeEncodeNpub(authorCandidate); + const valueToCopy = explicitNpub || fallbackNpub; + + if (!valueToCopy) { + this.callbacks.showError("No npub available to copy."); + break; + } + + if (!this.clipboard || typeof this.clipboard.writeText !== "function") { + this.callbacks.showError("Failed to copy the npub."); + break; + } + + try { + await this.clipboard.writeText(valueToCopy); + this.callbacks.showSuccess("Channel npub copied to clipboard!"); + } catch (error) { + console.error("Failed to copy npub:", error); + this.callbacks.showError("Failed to copy the npub."); + } + break; + } + case "blacklist-author": { + const actorNpub = this.callbacks.getCurrentUserNpub(); + if (!actorNpub) { + this.callbacks.showError( + "Please login as a moderator to manage the blacklist.", + ); + break; + } + + try { + await this.accessControl?.ensureReady?.(); + } catch (error) { + console.warn( + "Failed to refresh moderation state before blacklisting:", + error, + ); + } + + if (!this.accessControl?.canEditAdminLists?.(actorNpub)) { + this.callbacks.showError("Only moderators can manage the blacklist."); + break; + } + + let author = dataset.author || ""; + if (!author && context === "modal" && currentVideo?.pubkey) { + author = currentVideo.pubkey; + } + if (!author && currentVideo?.pubkey) { + author = currentVideo.pubkey; + } + + const explicitNpub = + typeof dataset.npub === "string" && dataset.npub.trim() + ? dataset.npub.trim() + : ""; + const targetNpub = explicitNpub || this.callbacks.safeEncodeNpub(author); + + if (!targetNpub) { + this.callbacks.showError("Unable to determine the creator npub."); + break; + } + + try { + const result = await this.accessControl.addToBlacklist( + actorNpub, + targetNpub, + ); + + if (result?.ok) { + this.callbacks.showSuccess("Creator added to the blacklist."); + try { + await this.subscriptions?.refreshActiveFeed?.({ + reason: "admin-blacklist-update", + }); + } catch (error) { + if (this.isDevMode) { + console.warn( + "[Subscriptions] Failed to refresh after blacklist update:", + error, + ); + } + } + } else { + const code = result?.error || "unknown"; + switch (code) { + case "self": + this.callbacks.showError("You cannot blacklist yourself."); + break; + case "immutable": + this.callbacks.showError( + "Moderators cannot blacklist the super admin or fellow moderators.", + ); + break; + case "invalid npub": + this.callbacks.showError("Unable to blacklist this creator."); + break; + case "forbidden": + this.callbacks.showError("Only moderators can manage the blacklist."); + break; + default: + this.callbacks.showError( + "Failed to update the blacklist. Please try again.", + ); + break; + } + } + } catch (error) { + console.error("Failed to add creator to blacklist:", error); + this.callbacks.showError( + "Failed to update the blacklist. Please try again.", + ); + } + break; + } + case "block-author": { + const currentUserPubkey = this.callbacks.getCurrentUserPubkey(); + if (!currentUserPubkey) { + this.callbacks.showError("Please login to manage your block list."); + break; + } + + const authorCandidate = + dataset.author || (currentVideo && currentVideo.pubkey) || ""; + + const trimmed = + typeof authorCandidate === "string" ? authorCandidate.trim() : ""; + if (!trimmed) { + this.callbacks.showError("Unable to determine the creator to block."); + break; + } + + let normalizedHex = ""; + if (trimmed.startsWith("npub1")) { + normalizedHex = this.callbacks.safeDecodeNpub(trimmed) || ""; + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + normalizedHex = trimmed.toLowerCase(); + } + + if (!normalizedHex) { + this.callbacks.showError("Unable to determine the creator to block."); + break; + } + + if (normalizedHex === currentUserPubkey) { + this.callbacks.showError("You cannot block yourself."); + break; + } + + try { + await this.userBlocks?.ensureLoaded?.(currentUserPubkey); + + if (this.userBlocks?.isBlocked?.(normalizedHex)) { + this.callbacks.showSuccess("You already blocked this creator."); + } else { + await this.userBlocks?.addBlock?.(normalizedHex, currentUserPubkey); + this.callbacks.showSuccess( + "Creator blocked. You won't see their videos anymore.", + ); + } + + try { + this.callbacks.onUserBlocksUpdated(); + } catch (error) { + console.warn( + "[MoreMenu] Failed to refresh blocked list after update:", + error, + ); + } + + await this.callbacks.loadVideos(); + try { + await this.subscriptions?.refreshActiveFeed?.({ + reason: "user-block-update", + }); + } catch (error) { + if (this.isDevMode) { + console.warn( + "[Subscriptions] Failed to refresh after user block update:", + error, + ); + } + } + } catch (error) { + console.error("Failed to update personal block list:", error); + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your block list. Please try again."; + this.callbacks.showError(message); + } + break; + } + case "report": { + this.callbacks.showSuccess("Reporting coming soon."); + break; + } + default: + break; + } + } +} diff --git a/tests/more-menu-controller.test.mjs b/tests/more-menu-controller.test.mjs new file mode 100644 index 00000000..45564253 --- /dev/null +++ b/tests/more-menu-controller.test.mjs @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import MoreMenuController from '../js/ui/moreMenuController.js'; + +test('copy-link action writes to clipboard and shows success', async () => { + let copied = null; + const clipboard = { + writeText: async (text) => { + copied = text; + }, + }; + + const messages = { error: [], success: [] }; + + const controller = new MoreMenuController({ + clipboard, + callbacks: { + buildShareUrlFromEventId: (eventId) => `https://example.com/watch/${eventId}`, + showError: (message) => messages.error.push(message), + showSuccess: (message) => messages.success.push(message), + getCurrentVideo: () => ({ id: 'fallback-id' }), + }, + }); + + await controller.handleMoreMenuAction('copy-link', { eventId: 'abc123' }); + await Promise.resolve(); + + assert.equal(copied, 'https://example.com/watch/abc123'); + assert.deepEqual(messages.error, []); + assert.deepEqual(messages.success, ['Video link copied to clipboard!']); +}); + +test('blacklist-author requires moderator access and refreshes subscriptions', async () => { + const calls = []; + const accessControl = { + ensureReady: async () => { + calls.push('ensureReady'); + }, + canEditAdminLists: (actorNpub) => { + calls.push(['canEdit', actorNpub]); + return true; + }, + addToBlacklist: async (actorNpub, targetNpub) => { + calls.push(['add', actorNpub, targetNpub]); + return { ok: true }; + }, + }; + + let refreshedWith = null; + const subscriptions = { + refreshActiveFeed: async (args) => { + refreshedWith = args; + }, + }; + + const controller = new MoreMenuController({ + accessControl, + subscriptions, + callbacks: { + getCurrentUserNpub: () => 'npub1actor', + getCurrentVideo: () => ({ pubkey: 'authorhex' }), + safeEncodeNpub: (pubkey) => `npub:${pubkey}`, + showError: (message) => { + throw new Error(`Unexpected error: ${message}`); + }, + showSuccess: (message) => { + calls.push(['success', message]); + }, + canCurrentUserManageBlacklist: () => true, + }, + }); + + await controller.handleMoreMenuAction('blacklist-author', { + author: 'authorhex', + }); + + assert.deepEqual(calls, [ + 'ensureReady', + ['canEdit', 'npub1actor'], + ['add', 'npub1actor', 'npub:authorhex'], + ['success', 'Creator added to the blacklist.'], + ]); + assert.deepEqual(refreshedWith, { reason: 'admin-blacklist-update' }); +}); + +test('blacklist-author shows error when no moderator session is available', async () => { + const errors = []; + const controller = new MoreMenuController({ + callbacks: { + getCurrentUserNpub: () => null, + showError: (message) => errors.push(message), + getCurrentVideo: () => ({ pubkey: 'authorhex' }), + }, + }); + + await controller.handleMoreMenuAction('blacklist-author', { + author: 'authorhex', + }); + + assert.deepEqual(errors, [ + 'Please login as a moderator to manage the blacklist.', + ]); +}); + +test('block-author updates user blocks, reloads videos, and refreshes feeds', async () => { + const events = []; + const userBlocks = { + ensureLoaded: async (pubkey) => { + events.push(['ensureLoaded', pubkey]); + }, + isBlocked: (pubkey) => { + events.push(['isBlocked', pubkey]); + return false; + }, + addBlock: async (target, actor) => { + events.push(['addBlock', target, actor]); + }, + }; + + const subscriptions = { + refreshActiveFeed: async (args) => { + events.push(['refreshActiveFeed', args]); + }, + }; + + const controller = new MoreMenuController({ + userBlocks, + subscriptions, + callbacks: { + getCurrentUserPubkey: () => 'actorhex', + getCurrentVideo: () => ({ pubkey: 'fallbackhex' }), + safeDecodeNpub: (npub) => (npub === 'npub1target' ? 'targethex' : ''), + showError: (message) => { + throw new Error(`Unexpected error: ${message}`); + }, + showSuccess: (message) => { + events.push(['success', message]); + }, + loadVideos: async () => { + events.push(['loadVideos']); + }, + onUserBlocksUpdated: () => { + events.push(['refreshBlockedList']); + }, + }, + }); + + await controller.handleMoreMenuAction('block-author', { + author: 'npub1target', + }); + + assert.deepEqual(events, [ + ['ensureLoaded', 'actorhex'], + ['isBlocked', 'targethex'], + ['addBlock', 'targethex', 'actorhex'], + ['success', "Creator blocked. You won't see their videos anymore."], + ['refreshBlockedList'], + ['loadVideos'], + ['refreshActiveFeed', { reason: 'user-block-update' }], + ]); +}); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index b913154e..cbbdf040 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -7,6 +7,8 @@ const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_CACHE_TTL_MS, + WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_BATCH_PAGE_SIZE, } = await import("../js/config.js"); const { @@ -58,6 +60,14 @@ const originalScheduleWatchHistoryRepublish = const originalResolveWatchHistory = nostrClient.resolveWatchHistory; const originalGetWatchHistoryFingerprint = nostrClient.getWatchHistoryFingerprint; +const originalExtensionPermissionCache = nostrClient.extensionPermissionCache; +const originalExtensionPermissionSnapshot = Array.isArray( + originalExtensionPermissionCache, +) + ? Array.from(originalExtensionPermissionCache) + : []; + +nostrClient.extensionPermissionCache = new Set(); nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { @@ -255,7 +265,12 @@ function installExtensionCrypto({ actor }) { let extensionEncrypts = 0; let extensionDecrypts = 0; let fallbackEncrypts = 0; + const enableCalls = []; window.nostr = { + enable: async (options) => { + enableCalls.push(options || null); + return { ok: true }; + }, signEvent: async (event) => ({ ...event, id: `ext-${event.kind}-${event.created_at}`, @@ -304,6 +319,7 @@ function installExtensionCrypto({ actor }) { getExtensionEncrypts: () => extensionEncrypts, getExtensionDecrypts: () => extensionDecrypts, getFallbackEncrypts: () => fallbackEncrypts, + getEnableCalls: () => [...enableCalls], }; } @@ -552,6 +568,11 @@ async function testPublishSnapshotUsesExtensionCrypto() { result.items.length, "decrypted payload should match canonical item count", ); + assert.equal( + extension.getEnableCalls().length, + 1, + "extension permissions should be requested once before encrypting", + ); } finally { extension.restore(); sessionRestore.restore(); @@ -561,6 +582,43 @@ async function testPublishSnapshotUsesExtensionCrypto() { } } +async function testEnsureExtensionPermissionCaching() { + const actor = "permission-actor"; + const extension = installExtensionCrypto({ actor }); + const previousCache = nostrClient.extensionPermissionCache; + nostrClient.extensionPermissionCache = new Set(); + + try { + const first = await nostrClient.ensureExtensionPermissions([ + "nip04.decrypt", + ]); + assert.equal( + first.ok, + true, + "ensureExtensionPermissions should resolve when extension is available", + ); + assert.equal( + extension.getEnableCalls().length > 0, + true, + "ensureExtensionPermissions should invoke extension.enable to request access", + ); + + const callsAfterFirst = extension.getEnableCalls().length; + const second = await nostrClient.ensureExtensionPermissions([ + "nip04.decrypt", + ]); + assert.equal(second.ok, true, "cached permission requests should still resolve"); + assert.equal( + extension.getEnableCalls().length, + callsAfterFirst, + "cached permission requests should not trigger duplicate enable calls", + ); + } finally { + extension.restore(); + nostrClient.extensionPermissionCache = previousCache; + } +} + async function testPublishSnapshotFailureRetry() { poolHarness.reset(); nostrClient.watchHistoryLastCreatedAt = 0; @@ -852,10 +910,22 @@ async function testResolveWatchHistoryBatchingWindow() { forceRefresh: true, }); + const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE); + const hasCustomBatchSize = + Boolean(WATCH_HISTORY_BATCH_RESOLVE) && + Number.isFinite(batchPageSizeRaw) && + batchPageSizeRaw > 0; + const expectedCount = hasCustomBatchSize + ? Math.min( + Math.floor(batchPageSizeRaw), + WATCH_HISTORY_MAX_ITEMS, + syntheticItems.length, + ) + : Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length); 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", + expectedCount, + "batched resolve should honor the configured page size when batching is enabled", ); assert(resolved.length > 1, "resolved history should include multiple entries"); assert.equal( @@ -1321,6 +1391,7 @@ async function testWatchHistoryAppLoginFallback() { await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); +await testEnsureExtensionPermissionCaching(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); @@ -1351,6 +1422,14 @@ nostrClient.resolveWatchHistory = originalResolveWatchHistory; nostrClient.getWatchHistoryFingerprint = originalGetWatchHistoryFingerprint; +if (originalExtensionPermissionCache instanceof Set) { + originalExtensionPermissionCache.clear(); + for (const method of originalExtensionPermissionSnapshot) { + originalExtensionPermissionCache.add(method); + } +} +nostrClient.extensionPermissionCache = originalExtensionPermissionCache; + if (!originalFlag) { setWatchHistoryV2Enabled(false); }