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/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' }], + ]); +});