diff --git a/js/app.js b/js/app.js index ff9380cc..7d070826 100644 --- a/js/app.js +++ b/js/app.js @@ -46,6 +46,8 @@ import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import createPopover from "./ui/overlay/popoverEngine.js"; +import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -703,6 +705,7 @@ class Application { }, }); this.moreMenuController.setVideoModal(this.videoModal); + this.videoSettingsPopovers = new Map(); // Hide/Show Subscriptions Link this.subscriptionsLink = null; @@ -4504,6 +4507,8 @@ class Application { if (this.moreMenuController) { this.moreMenuController.closeAllMoreMenus(options); } + + this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false }); } attachMoreMenuHandlers(container) { @@ -4512,6 +4517,191 @@ class Application { } } + requestMoreMenu(detail = {}) { + if (!this.moreMenuController) { + return; + } + + const trigger = detail.trigger || null; + if (!trigger) { + return; + } + + const capabilities = detail.capabilities || {}; + const canManage = + typeof capabilities.canManageBlacklist === "boolean" + ? capabilities.canManageBlacklist + : this.canCurrentUserManageBlacklist(); + + this.moreMenuController.toggleMoreMenu({ + trigger, + video: detail.video || null, + pointerInfo: detail.pointerInfo || null, + playbackUrl: detail.playbackUrl || "", + playbackMagnet: detail.playbackMagnet || "", + context: detail.context || "card", + canManageBlacklist: canManage, + designSystem: detail.designSystem || this.designSystemContext, + onAction: detail.onAction || null, + onClose: detail.onClose || null, + restoreFocusOnClose: detail.restoreFocus !== false, + }); + } + + closeMoreMenu(detail = {}) { + if (!this.moreMenuController) { + return false; + } + + const trigger = detail.trigger || null; + if (trigger) { + return this.moreMenuController.closePopoverForTrigger(trigger, { + restoreFocus: detail.restoreFocus !== false, + }); + } + + this.moreMenuController.closeAllMoreMenus({ + restoreFocus: detail.restoreFocus !== false, + skipView: detail.skipView === true, + }); + return true; + } + + ensureSettingsPopover(detail = {}) { + const trigger = detail.trigger || null; + if (!trigger) { + return null; + } + + let entry = this.videoSettingsPopovers.get(trigger); + if (!entry) { + entry = { + trigger, + context: { + card: detail.card || null, + video: detail.video || null, + index: Number.isFinite(detail.index) ? Math.floor(detail.index) : 0, + capabilities: detail.capabilities || {}, + restoreFocusOnClose: detail.restoreFocus !== false, + }, + popover: null, + }; + + const render = ({ document: documentRef, close }) => { + const panel = createVideoSettingsMenuPanel({ + document: documentRef, + video: entry.context.video, + index: entry.context.index, + capabilities: entry.context.capabilities, + designSystem: this.designSystemContext, + }); + + if (!panel) { + return null; + } + + const buttons = panel.querySelectorAll("button[data-action]"); + buttons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + const action = button.dataset.action || ""; + const handled = entry.context.card?.handleSettingsMenuAction?.( + action, + { event }, + ); + + if (!handled && this.isDevMode) { + userLogger.warn(`[SettingsMenu] Unhandled action: ${action}`); + } + + close(); + }); + }); + + return panel; + }; + + const documentRef = + trigger.ownerDocument || + (typeof document !== "undefined" ? document : null); + + const popover = createPopover(trigger, render, { + document: documentRef, + placement: "bottom-end", + }); + + const originalDestroy = popover.destroy?.bind(popover); + if (typeof originalDestroy === "function") { + popover.destroy = (...args) => { + originalDestroy(...args); + if (this.videoSettingsPopovers.get(trigger) === entry) { + this.videoSettingsPopovers.delete(trigger); + } + }; + } + + entry.popover = popover; + this.videoSettingsPopovers.set(trigger, entry); + } + + entry.context = { + ...entry.context, + card: detail.card || entry.context.card, + video: detail.video || entry.context.video, + index: Number.isFinite(detail.index) + ? Math.floor(detail.index) + : entry.context.index, + capabilities: detail.capabilities || entry.context.capabilities, + restoreFocusOnClose: detail.restoreFocus !== false, + }; + + return entry; + } + + requestVideoSettingsMenu(detail = {}) { + const entry = this.ensureSettingsPopover(detail); + if (!entry?.popover) { + return; + } + + if (typeof entry.popover.isOpen === "function" && entry.popover.isOpen()) { + entry.popover.close({ + restoreFocus: entry.context.restoreFocusOnClose !== false, + }); + return; + } + + entry.popover + .open() + .catch((error) => + userLogger.error("[SettingsMenu] Failed to open popover:", error), + ); + } + + closeVideoSettingsMenu(detail = {}) { + const trigger = detail.trigger || null; + const restoreFocus = detail.restoreFocus !== false; + + if (trigger) { + const entry = this.videoSettingsPopovers.get(trigger); + if (entry?.popover && typeof entry.popover.close === "function") { + return entry.popover.close({ restoreFocus }); + } + return false; + } + + let closed = false; + this.videoSettingsPopovers.forEach((entry) => { + if (entry?.popover && typeof entry.popover.close === "function") { + const result = entry.popover.close({ restoreFocus }); + closed = closed || result; + } + }); + return closed; + } + syncModalMoreMenuData() { if (this.moreMenuController) { this.moreMenuController.syncModalMoreMenuData(); diff --git a/js/channelProfile.js b/js/channelProfile.js index e2c51ee3..f6e3af7e 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2074,6 +2074,36 @@ function renderChannelVideosFromList({ } }; + videoCard.onRequestMoreMenu = (detail = {}) => { + if (typeof app?.requestMoreMenu === "function") { + app.requestMoreMenu({ + ...detail, + video: detail.video || video, + pointerInfo: detail.pointerInfo || pointerInfo, + }); + } + }; + + videoCard.onCloseMoreMenu = (detail = {}) => { + if (typeof app?.closeMoreMenu === "function") { + return app.closeMoreMenu(detail); + } + return false; + }; + + videoCard.onRequestSettingsMenu = (detail = {}) => { + if (typeof app?.requestVideoSettingsMenu === "function") { + app.requestVideoSettingsMenu(detail); + } + }; + + videoCard.onCloseSettingsMenu = (detail = {}) => { + if (typeof app?.closeVideoSettingsMenu === "function") { + return app.closeVideoSettingsMenu(detail); + } + return false; + }; + const cardEl = videoCard.getRoot(); if (cardEl) { fragment.appendChild(cardEl); diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index ee3e8757..a5c7ee9f 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -1,12 +1,6 @@ import { normalizeDesignSystemContext } from "../../designSystem.js"; import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibility.js"; -import positionFloatingPanel from "../utils/positionFloatingPanel.js"; -import { createFloatingPanelStyles } from "../utils/floatingPanelStyles.js"; import { userLogger } from "../../utils/logger.js"; -import { - getPopupOffsetPx, - getPopupViewportPaddingPx, -} from "../../designSystem/metrics.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; @@ -107,7 +101,11 @@ export class VideoCard { onRevert: null, onDelete: null, onMoreAction: null, - onAuthorNavigate: null + onAuthorNavigate: null, + onRequestMoreMenu: null, + onCloseMoreMenu: null, + onRequestSettingsMenu: null, + onCloseSettingsMenu: null }; this.root = null; @@ -115,14 +113,7 @@ export class VideoCard { this.titleEl = null; this.thumbnailEl = null; this.settingsButton = null; - this.settingsDropdown = null; - this.editButton = null; - this.revertButton = null; - this.deleteButton = null; this.moreMenuButton = null; - this.moreMenu = null; - this.settingsDropdownPositioner = null; - this.moreMenuPositioner = null; this.urlHealthBadgeEl = null; this.torrentHealthBadgeEl = null; this.viewCountEl = null; @@ -195,6 +186,24 @@ export class VideoCard { this.callbacks.onAuthorNavigate = typeof fn === "function" ? fn : null; } + set onRequestMoreMenu(fn) { + this.callbacks.onRequestMoreMenu = typeof fn === "function" ? fn : null; + } + + set onCloseMoreMenu(fn) { + this.callbacks.onCloseMoreMenu = typeof fn === "function" ? fn : null; + } + + set onRequestSettingsMenu(fn) { + this.callbacks.onRequestSettingsMenu = + typeof fn === "function" ? fn : null; + } + + set onCloseSettingsMenu(fn) { + this.callbacks.onCloseSettingsMenu = + typeof fn === "function" ? fn : null; + } + getRoot() { return this.root; } @@ -215,25 +224,67 @@ export class VideoCard { return this.discussionCountEl; } - closeMoreMenu() { - if (this.moreMenu) { - this.moreMenu.dataset.state = "closed"; - this.moreMenu.setAttribute("aria-hidden", "true"); - this.moreMenu.hidden = true; + closeMoreMenu(options = {}) { + const restoreFocus = options?.restoreFocus !== false; + const detail = { + trigger: this.moreMenuButton, + video: this.video, + card: this, + restoreFocus, + }; + + let handled = false; + if (this.callbacks.onCloseMoreMenu) { + try { + handled = this.callbacks.onCloseMoreMenu(detail) === true; + } catch (error) { + if (this.window?.userLogger?.warn) { + this.window.userLogger.warn( + "[VideoCard] onCloseMoreMenu callback failed", + error, + ); + } + } } - if (this.moreMenuButton) { - this.moreMenuButton.setAttribute("aria-expanded", "false"); + + if (!handled && restoreFocus && this.moreMenuButton?.focus) { + try { + this.moreMenuButton.focus(); + } catch (error) { + /* noop */ + } } } - closeSettingsMenu() { - if (this.settingsDropdown) { - this.settingsDropdown.dataset.state = "closed"; - this.settingsDropdown.setAttribute("aria-hidden", "true"); - this.settingsDropdown.hidden = true; + closeSettingsMenu(options = {}) { + const restoreFocus = options?.restoreFocus !== false; + const detail = { + trigger: this.settingsButton, + video: this.video, + card: this, + restoreFocus, + }; + + let handled = false; + if (this.callbacks.onCloseSettingsMenu) { + try { + handled = this.callbacks.onCloseSettingsMenu(detail) === true; + } catch (error) { + if (this.window?.userLogger?.warn) { + this.window.userLogger.warn( + "[VideoCard] onCloseSettingsMenu callback failed", + error, + ); + } + } } - if (this.settingsButton) { - this.settingsButton.setAttribute("aria-expanded", "false"); + + if (!handled && restoreFocus && this.settingsButton?.focus) { + try { + this.settingsButton.focus(); + } catch (error) { + /* noop */ + } } } @@ -706,9 +757,6 @@ export class VideoCard { } if (this.capabilities.canEdit) { - const wrapper = this.createElement("div", { - classNames: ["popover", "ml-2"] - }); const button = this.createElement("button", { classNames: [ "btn-ghost", @@ -716,133 +764,28 @@ export class VideoCard { "w-10", "rounded-full", "p-0", - "text-muted" + "text-muted", + "ml-2", ], attrs: { type: "button", "aria-haspopup": "true", "aria-expanded": "false", - "aria-label": "Video settings" - } + "aria-label": "Video settings", + }, }); - button.dataset.settingsDropdown = String(this.index); const icon = this.createSettingsIcon(["h-5", "w-5"]); button.appendChild(icon); - const dropdown = this.createElement("div", { - classNames: ["popover__panel", "w-44", "p-0"], - attrs: { - hidden: "", - "data-state": "closed", - "aria-hidden": "true" - } - }); - dropdown.id = `settingsDropdown-${this.index}`; - dropdown.setAttribute("role", "menu"); - - const list = this.createElement("div", { - classNames: ["menu"], - attrs: { role: "none" } - }); - - const addMenuItem = ({ text, variant = null, dataset = {} }) => { - const item = this.createElement("button", { - classNames: ["menu__item", "justify-start"], - textContent: text, - attrs: { - type: "button", - role: "menuitem" - } - }); - if (variant) { - item.dataset.variant = variant; - } - Object.entries(dataset).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - item.dataset[key] = String(value); - }); - list.appendChild(item); - return item; - }; - - const editButton = addMenuItem({ - text: "Edit", - dataset: { - editIndex: String(this.index), - editEventId: this.video.id - } - }); - this.editButton = editButton; - - if (this.capabilities.canRevert) { - const revertButton = addMenuItem({ - text: "Revert", - variant: "critical", - dataset: { - revertIndex: String(this.index), - revertEventId: this.video.id - } - }); - this.revertButton = revertButton; - } - - if (this.capabilities.canDelete) { - const deleteButton = addMenuItem({ - text: "Delete All", - variant: "critical", - dataset: { - deleteAllIndex: String(this.index), - deleteAllEventId: this.video.id - } - }); - this.deleteButton = deleteButton; - } - - dropdown.appendChild(list); - wrapper.appendChild(button); - wrapper.appendChild(dropdown); - - container.appendChild(wrapper); - this.settingsButton = button; - this.settingsDropdown = dropdown; - this.settingsDropdownStyles = createFloatingPanelStyles(dropdown); - const metricsDocument = - dropdown?.ownerDocument || - button?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - const settingsOffset = getPopupOffsetPx({ - documentRef: metricsDocument, - }); - const settingsViewportPadding = getPopupViewportPaddingPx({ - documentRef: metricsDocument, - }); - this.settingsDropdownPositioner = positionFloatingPanel( - button, - dropdown, - { - placement: "bottom", - alignment: "end", - offset: settingsOffset, - viewportPadding: settingsViewportPadding, - styles: this.settingsDropdownStyles, - }, - ); + container.appendChild(button); } return container; } buildMoreMenu() { - const wrapper = this.createElement("div", { - classNames: ["popover", "ml-1"] - }); - wrapper.dataset.moreMenuWrapper = "true"; - const button = this.createElement("button", { classNames: [ "btn-ghost", @@ -850,208 +793,23 @@ export class VideoCard { "w-10", "rounded-full", "p-0", - "text-muted" + "text-muted", + "ml-1", ], attrs: { type: "button", "aria-haspopup": "true", "aria-expanded": "false", - "aria-label": "More options" - } + "aria-label": "More options", + }, }); - button.dataset.moreDropdown = String(this.index); - button.dataset.moreMenuToggleBound = "true"; const icon = this.createEllipsisIcon(["w-5", "h-5", "object-contain"]); button.appendChild(icon); - const dropdown = this.createElement("div", { - classNames: ["popover__panel", "w-40", "p-0"], - attrs: { - hidden: "", - "data-state": "closed", - "aria-hidden": "true" - } - }); - dropdown.id = `moreDropdown-${this.index}`; - dropdown.dataset.moreMenu = "true"; - dropdown.setAttribute("role", "menu"); - - const list = this.createElement("div", { - classNames: ["menu"], - attrs: { role: "none" } - }); - - const addActionButton = (text, action, extraDataset = {}, options = {}) => { - const { variant = null } = options || {}; - const btn = this.createElement("button", { - classNames: ["menu__item", "justify-start"], - textContent: text, - attrs: { - type: "button", - role: "menuitem" - } - }); - if (variant) { - btn.dataset.variant = variant; - } - btn.dataset.action = action; - Object.entries(extraDataset || {}).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - btn.dataset[key] = String(value); - }); - list.appendChild(btn); - return btn; - }; - - addActionButton("Open channel", "open-channel", { - author: this.video.pubkey || "" - }); - - addActionButton("Copy link", "copy-link", { - eventId: this.video.id || "" - }); - - const pointer = Array.isArray(this.pointerInfo?.pointer) - ? this.pointerInfo.pointer - : null; - const pointerType = pointer && pointer.length >= 2 ? pointer[0] : ""; - const pointerValue = pointer && pointer.length >= 2 ? pointer[1] : ""; - const pointerRelay = pointer && pointer.length >= 3 ? pointer[2] || "" : ""; - const numericKind = - Number.isFinite(this.video.kind) && this.video.kind > 0 - ? Math.floor(this.video.kind) - : null; - - const baseBoostDataset = { - eventId: this.video.id || "", - author: this.video.pubkey || "" - }; - - if (pointerType && pointerValue) { - baseBoostDataset.pointerType = pointerType; - baseBoostDataset.pointerValue = pointerValue; - } - if (pointerRelay) { - baseBoostDataset.pointerRelay = pointerRelay; - } - if (Number.isFinite(numericKind)) { - baseBoostDataset.kind = String(numericKind); - } - - const boostLabel = this.createElement("div", { - classNames: ["menu__heading"], - textContent: "Boost on Nostr…" - }); - list.appendChild(boostLabel); - - addActionButton("Repost (kind 6)", "repost-event", baseBoostDataset); - - if (this.playbackUrl && this.video.isPrivate !== true) { - const mirrorDataset = { - ...baseBoostDataset, - url: this.playbackUrl, - magnet: this.playbackMagnet || "", - thumbnail: - typeof this.video.thumbnail === "string" ? this.video.thumbnail : "", - description: - typeof this.video.description === "string" - ? this.video.description - : "", - title: typeof this.video.title === "string" ? this.video.title : "", - isPrivate: this.video.isPrivate === true ? "true" : "false" - }; - - addActionButton("Mirror (kind 1063)", "mirror-video", mirrorDataset); - } - - const ensureDataset = { - ...baseBoostDataset, - pubkey: this.video.pubkey || "" - }; - - addActionButton("Rebroadcast", "ensure-presence", ensureDataset); - - list.appendChild( - this.createElement("div", { - classNames: ["menu__separator"], - attrs: { role: "separator" } - }) - ); - - if (this.pointerInfo && this.pointerInfo.key && this.pointerInfo.pointer) { - const [historyPointerType, historyPointerValue, historyPointerRelay] = - this.pointerInfo.pointer; - if (historyPointerType && historyPointerValue) { - const removeButton = addActionButton( - "Remove from history", - "remove-history", - { - pointerKey: this.pointerInfo.key, - pointerType: historyPointerType, - pointerValue: historyPointerValue, - pointerRelay: historyPointerRelay || "", - reason: "remove-item" - } - ); - removeButton.setAttribute( - "title", - "Remove this entry from your encrypted history. Relay sync may take a moment." - ); - removeButton.setAttribute( - "aria-label", - "Remove from history (updates encrypted history and may take a moment to sync to relays)" - ); - } - } - - if (this.capabilities.canManageBlacklist) { - addActionButton( - "Blacklist creator", - "blacklist-author", - { author: this.video.pubkey || "" }, - { variant: "critical" } - ); - } - - addActionButton( - "Block creator", - "block-author", - { author: this.video.pubkey || "" }, - { variant: "critical" } - ); - - addActionButton("Report", "report", { eventId: this.video.id || "" }); - - dropdown.appendChild(list); - wrapper.appendChild(button); - wrapper.appendChild(dropdown); - this.moreMenuButton = button; - this.moreMenu = dropdown; - this.moreMenuStyles = createFloatingPanelStyles(dropdown); - const menuMetricsDocument = - dropdown?.ownerDocument || - button?.ownerDocument || - this.document || - (typeof document !== "undefined" ? document : null); - const menuOffset = getPopupOffsetPx({ - documentRef: menuMetricsDocument, - }); - const menuViewportPadding = getPopupViewportPaddingPx({ - documentRef: menuMetricsDocument, - }); - this.moreMenuPositioner = positionFloatingPanel(button, dropdown, { - placement: "bottom", - alignment: "end", - offset: menuOffset, - viewportPadding: menuViewportPadding, - styles: this.moreMenuStyles, - }); - return wrapper; + return button; } buildBadgesContainer() { @@ -1637,106 +1395,70 @@ export class VideoCard { }); }); - if (this.settingsButton && this.settingsDropdown) { + if (this.settingsButton) { this.settingsButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - const willOpen = this.settingsDropdown.dataset.state !== "open"; + if (this.onRequestCloseAllMenus) { this.onRequestCloseAllMenus(this); } else { - this.closeSettingsMenu(); + this.closeSettingsMenu({ restoreFocus: false }); } - if (willOpen) { - this.settingsDropdown.hidden = false; - this.settingsDropdown.dataset.state = "open"; - this.settingsDropdown.setAttribute("aria-hidden", "false"); - this.settingsButton.setAttribute("aria-expanded", "true"); - this.settingsDropdownPositioner?.update(); - } - }); - } - if (this.editButton) { - this.editButton.addEventListener("click", (event) => { - event.preventDefault(); - this.closeSettingsMenu(); - if (this.callbacks.onEdit) { - this.callbacks.onEdit({ + if (this.callbacks.onRequestSettingsMenu) { + const detail = { event, + trigger: this.settingsButton, video: this.video, + card: this, index: this.index, - card: this - }); + capabilities: { ...this.capabilities }, + designSystem: this.designSystem, + }; + this.callbacks.onRequestSettingsMenu(detail); } }); } - if (this.revertButton) { - this.revertButton.addEventListener("click", (event) => { - event.preventDefault(); - this.closeSettingsMenu(); - if (this.callbacks.onRevert) { - this.callbacks.onRevert({ - event, - video: this.video, - index: this.index, - card: this - }); - } - }); - } - - if (this.deleteButton) { - this.deleteButton.addEventListener("click", (event) => { - event.preventDefault(); - this.closeSettingsMenu(); - if (this.callbacks.onDelete) { - this.callbacks.onDelete({ - event, - video: this.video, - index: this.index, - card: this - }); - } - }); - } - - if (this.moreMenuButton && this.moreMenu) { + if (this.moreMenuButton) { this.moreMenuButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - const willOpen = this.moreMenu.dataset.state !== "open"; + if (this.onRequestCloseAllMenus) { this.onRequestCloseAllMenus(this); } - if (willOpen) { - this.moreMenu.hidden = false; - this.moreMenu.dataset.state = "open"; - this.moreMenu.setAttribute("aria-hidden", "false"); - this.moreMenuButton.setAttribute("aria-expanded", "true"); - this.moreMenuPositioner?.update(); - } - }); - const actionButtons = this.moreMenu.querySelectorAll( - "button[data-action]" - ); - actionButtons.forEach((button) => { - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - if (this.callbacks.onMoreAction) { - const dataset = { ...button.dataset }; - this.callbacks.onMoreAction({ - event, - video: this.video, - card: this, - dataset - }); - } - this.closeMoreMenu(); - }); + if (this.callbacks.onRequestMoreMenu) { + const actionForwarder = this.callbacks.onMoreAction + ? ({ action, dataset, event: actionEvent }) => { + const payload = { + ...dataset, + action: action || dataset?.action || "", + }; + this.callbacks.onMoreAction({ + event: actionEvent, + video: this.video, + card: this, + dataset: payload, + }); + } + : null; + + this.callbacks.onRequestMoreMenu({ + event, + trigger: this.moreMenuButton, + video: this.video, + card: this, + pointerInfo: this.pointerInfo, + playbackUrl: this.playbackUrl, + playbackMagnet: this.playbackMagnet, + capabilities: { ...this.capabilities }, + designSystem: this.designSystem, + onAction: actionForwarder, + }); + } }); } @@ -1783,6 +1505,45 @@ export class VideoCard { } } + handleSettingsMenuAction(action, { event = null } = {}) { + const normalized = typeof action === "string" ? action.trim() : ""; + if (!normalized) { + return false; + } + + if (normalized === "edit" && this.callbacks.onEdit) { + this.callbacks.onEdit({ + event, + video: this.video, + index: this.index, + card: this, + }); + return true; + } + + if (normalized === "revert" && this.callbacks.onRevert) { + this.callbacks.onRevert({ + event, + video: this.video, + index: this.index, + card: this, + }); + return true; + } + + if (normalized === "delete" && this.callbacks.onDelete) { + this.callbacks.onDelete({ + event, + video: this.video, + index: this.index, + card: this, + }); + return true; + } + + return false; + } + createElement(tagName, { classNames = [], attrs = {}, textContent } = {}) { const el = this.document.createElement(tagName); classNames diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js new file mode 100644 index 00000000..b6d5e4e8 --- /dev/null +++ b/js/ui/components/videoMenuRenderers.js @@ -0,0 +1,344 @@ +import { normalizeDesignSystemContext } from "../../designSystem.js"; + +function resolveDocument(documentRef) { + if (documentRef && documentRef.nodeType === 9) { + return documentRef; + } + if (typeof document !== "undefined" && document?.nodeType === 9) { + return document; + } + return null; +} + +function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) { + const el = doc.createElement(tagName); + classNames + .filter((name) => typeof name === "string" && name.trim()) + .forEach((name) => el.classList.add(name)); + Object.entries(attrs).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + return; + } + el.setAttribute(key, value); + }); + if (typeof textContent === "string") { + el.textContent = textContent; + } + return el; +} + +function normalizePointer(pointerInfo) { + if (!pointerInfo || typeof pointerInfo !== "object") { + return null; + } + + const pointer = Array.isArray(pointerInfo.pointer) + ? pointerInfo.pointer + : null; + if (!pointer || pointer.length < 2) { + return null; + } + + const [type, value, relay] = pointer; + const normalized = { + key: typeof pointerInfo.key === "string" ? pointerInfo.key : "", + type: typeof type === "string" ? type : "", + value: typeof value === "string" ? value : "", + relay: typeof relay === "string" ? relay : "", + }; + + if (!normalized.type || !normalized.value) { + return null; + } + + return normalized; +} + +function resolveVideoMetadata(video) { + if (!video || typeof video !== "object") { + return { + id: "", + pubkey: "", + title: "", + description: "", + thumbnail: "", + isPrivate: false, + kind: null, + }; + } + + const normalized = { + id: typeof video.id === "string" ? video.id : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + title: typeof video.title === "string" ? video.title : "", + description: + typeof video.description === "string" ? video.description : "", + thumbnail: + typeof video.thumbnail === "string" ? video.thumbnail : "", + isPrivate: video.isPrivate === true, + }; + + if (Number.isFinite(video.kind) && video.kind > 0) { + normalized.kind = Math.floor(video.kind); + } else { + normalized.kind = null; + } + + return normalized; +} + +function ensureMenuContainer(doc, panelClassNames = []) { + const panel = createElement(doc, "div", { + classNames: ["popover__panel", ...panelClassNames], + attrs: { role: "menu" }, + }); + + const list = createElement(doc, "div", { + classNames: ["menu"], + attrs: { role: "none" }, + }); + + panel.appendChild(list); + return { panel, list }; +} + +function appendMenuSeparator(doc, list) { + const separator = createElement(doc, "div", { + classNames: ["menu__separator"], + attrs: { role: "separator" }, + }); + list.appendChild(separator); + return separator; +} + +function appendMenuHeading(doc, list, text) { + const heading = createElement(doc, "div", { + classNames: ["menu__heading"], + textContent: text, + }); + list.appendChild(heading); + return heading; +} + +function appendMenuAction(doc, list, { + text, + action, + dataset = {}, + variant = null, + context = "", +}) { + const button = createElement(doc, "button", { + classNames: ["menu__item", "justify-start"], + textContent: text, + attrs: { type: "button", role: "menuitem" }, + }); + + button.dataset.action = action; + if (variant) { + button.dataset.variant = variant; + } + if (context) { + button.dataset.context = context; + } + + Object.entries(dataset).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + button.dataset[key] = String(value); + }); + + list.appendChild(button); + return button; +} + +export function createVideoMoreMenuPanel({ + document: documentRef = null, + video = null, + pointerInfo = null, + playbackUrl = "", + playbackMagnet = "", + canManageBlacklist = false, + context = "card", + designSystem = null, +} = {}) { + const doc = resolveDocument(documentRef); + if (!doc) { + return null; + } + normalizeDesignSystemContext(designSystem); + + const metadata = resolveVideoMetadata(video); + const pointer = normalizePointer(pointerInfo); + + const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]); + panel.dataset.menu = "video-more"; + panel.dataset.menuContext = context; + + appendMenuAction(doc, list, { + text: "Open channel", + action: "open-channel", + dataset: { author: metadata.pubkey, context }, + }); + + appendMenuAction(doc, list, { + text: "Copy link", + action: "copy-link", + dataset: { eventId: metadata.id, context }, + }); + + const baseBoostDataset = { + eventId: metadata.id, + author: metadata.pubkey, + context, + }; + + if (pointer) { + baseBoostDataset.pointerType = pointer.type; + baseBoostDataset.pointerValue = pointer.value; + if (pointer.relay) { + baseBoostDataset.pointerRelay = pointer.relay; + } + if (pointer.key) { + baseBoostDataset.pointerKey = pointer.key; + } + } + + if (Number.isFinite(metadata.kind) && metadata.kind > 0) { + baseBoostDataset.kind = String(metadata.kind); + } + + appendMenuHeading(doc, list, "Boost on Nostr…"); + + appendMenuAction(doc, list, { + text: "Repost (kind 6)", + action: "repost-event", + dataset: baseBoostDataset, + }); + + if (playbackUrl && metadata.isPrivate !== true) { + appendMenuAction(doc, list, { + text: "Mirror (kind 1063)", + action: "mirror-video", + dataset: { + ...baseBoostDataset, + url: playbackUrl, + magnet: playbackMagnet || "", + thumbnail: metadata.thumbnail, + description: metadata.description, + title: metadata.title, + isPrivate: metadata.isPrivate ? "true" : "false", + }, + }); + } + + appendMenuAction(doc, list, { + text: "Rebroadcast", + action: "ensure-presence", + dataset: { + ...baseBoostDataset, + pubkey: metadata.pubkey, + }, + }); + + appendMenuSeparator(doc, list); + + if (pointer) { + appendMenuAction(doc, list, { + text: "Remove from history", + action: "remove-history", + dataset: { + pointerKey: pointer.key, + pointerType: pointer.type, + pointerValue: pointer.value, + pointerRelay: pointer.relay, + reason: "remove-item", + }, + }); + } + + if (canManageBlacklist) { + appendMenuAction(doc, list, { + text: "Blacklist creator", + action: "blacklist-author", + variant: "critical", + dataset: { author: metadata.pubkey }, + }); + } + + appendMenuAction(doc, list, { + text: "Block creator", + action: "block-author", + variant: "critical", + dataset: { author: metadata.pubkey }, + }); + + appendMenuAction(doc, list, { + text: "Report", + action: "report", + dataset: { eventId: metadata.id }, + }); + + return panel; +} + +export function createVideoSettingsMenuPanel({ + document: documentRef = null, + video = null, + index = 0, + capabilities = {}, + designSystem = null, +} = {}) { + const doc = resolveDocument(documentRef); + if (!doc) { + return null; + } + normalizeDesignSystemContext(designSystem); + + const metadata = resolveVideoMetadata(video); + const normalizedIndex = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0; + const perms = { + canEdit: capabilities?.canEdit === true, + canRevert: capabilities?.canRevert === true, + canDelete: capabilities?.canDelete === true, + }; + + const { panel, list } = ensureMenuContainer(doc, ["w-44", "p-0"]); + panel.dataset.menu = "video-settings"; + + appendMenuAction(doc, list, { + text: "Edit", + action: "edit", + dataset: { + index: String(normalizedIndex), + eventId: metadata.id, + }, + }); + + if (perms.canRevert) { + appendMenuAction(doc, list, { + text: "Revert", + action: "revert", + variant: "critical", + dataset: { + index: String(normalizedIndex), + eventId: metadata.id, + }, + }); + } + + if (perms.canDelete) { + appendMenuAction(doc, list, { + text: "Delete All", + action: "delete", + variant: "critical", + dataset: { + index: String(normalizedIndex), + eventId: metadata.id, + }, + }); + } + + return panel; +} + diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index c02086fa..5c1c35e0 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -1,5 +1,8 @@ import { normalizeDesignSystemContext } from "../designSystem.js"; import { userLogger } from "../utils/logger.js"; +import { + createVideoMoreMenuPanel, +} from "./components/videoMenuRenderers.js"; import createPopover from "./overlay/popoverEngine.js"; export default class MoreMenuController { @@ -50,8 +53,6 @@ export default class MoreMenuController { }; this.moreMenuGlobalHandlerBound = false; - this.boundDocumentClick = null; - this.boundDocumentKeydown = null; this.videoListView = null; this.videoModal = null; @@ -169,9 +170,9 @@ export default class MoreMenuController { this.closeAllMoreMenus({ skipView: true }); - this.popoversByTrigger.forEach((popover) => { + this.popoversByTrigger.forEach((entry) => { try { - popover?.destroy?.(); + entry?.popover?.destroy?.(); } catch (error) { if (this.isDevMode) { userLogger.warn("[MoreMenu] Failed to destroy popover:", error); @@ -180,53 +181,11 @@ export default class MoreMenuController { }); this.popoversByTrigger.clear(); this.activePopover = 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); } getHTMLElementConstructor(documentRef) { @@ -236,46 +195,22 @@ export default class MoreMenuController { ); } - resolveMenuTemplate(key, documentRef) { - if (!documentRef || typeof documentRef.getElementById !== "function") { - return null; - } + createPopoverRender(entry) { + return ({ document: documentRef, close }) => { + const panel = createVideoMoreMenuPanel({ + document: documentRef, + video: entry.context.video, + pointerInfo: entry.context.pointerInfo, + playbackUrl: entry.context.playbackUrl, + playbackMagnet: entry.context.playbackMagnet, + canManageBlacklist: entry.context.canManageBlacklist, + context: entry.context.context || "card", + designSystem: entry.context.designSystem || this.designSystem, + }); - const identifier = typeof key === "string" && key ? key : ""; - if (!identifier) { - return null; - } - - const template = documentRef.getElementById(`moreDropdown-${identifier}`); - const HTMLElementCtor = this.getHTMLElementConstructor(documentRef); - if (HTMLElementCtor && !(template instanceof HTMLElementCtor)) { - return null; - } - - return template || null; - } - - resetMenuTemplateState(template) { - if (!template) { - return; - } - - template.hidden = true; - template.setAttribute("aria-hidden", "true"); - template.dataset.state = "closed"; - } - - createMenuRenderFactory({ template }) { - if (!template) { - return null; - } - - return ({ close }) => { - const panel = template.cloneNode(true); - panel.removeAttribute("id"); - panel.hidden = false; - panel.setAttribute("aria-hidden", "false"); - panel.dataset.state = "open"; + if (!panel) { + return null; + } const buttons = panel.querySelectorAll("button[data-action]"); buttons.forEach((button) => { @@ -284,15 +219,35 @@ export default class MoreMenuController { return; } - button.addEventListener("click", (event) => { + button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); + const dataset = { ...button.dataset }; const action = dataset.action || ""; + + try { + await this.handleMoreMenuAction(action, dataset); + } catch (error) { + userLogger.error("[MoreMenu] Failed to handle action:", error); + } + + if (typeof entry.context.onAction === "function") { + try { + entry.context.onAction({ action, dataset, event }); + } catch (callbackError) { + if (this.isDevMode) { + userLogger.warn( + "[MoreMenu] onAction callback failed", + callbackError, + ); + } + } + } + if (typeof close === "function") { close(); } - this.handleMoreMenuAction(action, dataset); }); }); @@ -300,7 +255,7 @@ export default class MoreMenuController { }; } - decoratePopoverLifecycle(popover, trigger) { + decoratePopoverLifecycle(popover, trigger, entry) { if (!popover) { return null; } @@ -320,11 +275,28 @@ export default class MoreMenuController { } if (typeof originalClose === "function") { - popover.close = (options) => { - const result = originalClose(options); + popover.close = (options = {}) => { + const restoreFocus = + options?.restoreFocus !== undefined + ? options.restoreFocus + : entry?.context?.restoreFocusOnClose !== false; + const result = originalClose({ restoreFocus }); if (result && this.activePopover === popover) { this.activePopover = null; } + if (result && typeof entry?.context?.onClose === "function") { + try { + entry.context.onClose({ + trigger, + reason: options?.reason || "close", + restoreFocus, + }); + } catch (error) { + if (this.isDevMode) { + userLogger.warn("[MoreMenu] onClose callback failed", error); + } + } + } return result; }; } @@ -335,7 +307,7 @@ export default class MoreMenuController { this.activePopover = null; } originalDestroy(...args); - if (trigger && this.popoversByTrigger.get(trigger) === popover) { + if (trigger && this.popoversByTrigger.get(trigger) === entry) { this.popoversByTrigger.delete(trigger); } }; @@ -344,7 +316,7 @@ export default class MoreMenuController { return popover; } - ensurePopoverForTrigger({ trigger, key }) { + ensurePopoverForTrigger({ trigger }) { if (!trigger) { return null; } @@ -358,46 +330,98 @@ export default class MoreMenuController { this.document || (typeof document !== "undefined" ? document : null); - const template = this.resolveMenuTemplate(key, documentRef); - if (!template) { - return null; - } + const entry = { + trigger, + context: { + video: null, + pointerInfo: null, + playbackUrl: "", + playbackMagnet: "", + canManageBlacklist: false, + context: "card", + designSystem: this.designSystem, + onAction: null, + onClose: null, + restoreFocusOnClose: true, + }, + popover: null, + }; - this.resetMenuTemplateState(template); - - const render = this.createMenuRenderFactory({ template }); - if (typeof render !== "function") { - return null; - } + const render = this.createPopoverRender(entry); const popover = createPopover(trigger, render, { document: documentRef, placement: "bottom-end", }); - const decorated = this.decoratePopoverLifecycle(popover, trigger) || popover; - this.popoversByTrigger.set(trigger, decorated); - return decorated; + const decorated = this.decoratePopoverLifecycle(popover, trigger, entry) || popover; + entry.popover = decorated; + this.popoversByTrigger.set(trigger, entry); + return entry; } - async toggleMoreMenu({ trigger, key }) { - const popover = this.ensurePopoverForTrigger({ trigger, key }); - if (!popover) { + async toggleMoreMenu(detail = {}) { + const { + trigger = null, + video = null, + pointerInfo = null, + playbackUrl = "", + playbackMagnet = "", + context = "card", + canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(), + designSystem = null, + onAction = null, + onClose = null, + restoreFocusOnClose = true, + } = detail; + + const entry = this.ensurePopoverForTrigger({ trigger }); + if (!entry?.popover) { return; } - if (typeof popover.isOpen === "function" && popover.isOpen()) { - popover.close(); + entry.context = { + ...entry.context, + video, + pointerInfo, + playbackUrl, + playbackMagnet, + canManageBlacklist, + context, + designSystem: designSystem || this.designSystem, + onAction, + onClose, + restoreFocusOnClose: restoreFocusOnClose !== false, + }; + + if (typeof entry.popover.isOpen === "function" && entry.popover.isOpen()) { + entry.popover.close({ + restoreFocus: entry.context.restoreFocusOnClose !== false, + }); return; } try { - await popover.open(); + await entry.popover.open(); } catch (error) { userLogger.error("[MoreMenu] Failed to open popover:", error); } } + closePopoverForTrigger(trigger, options = {}) { + const entry = trigger ? this.popoversByTrigger.get(trigger) : null; + if (!entry?.popover || typeof entry.popover.close !== "function") { + return false; + } + + const restoreFocus = + options?.restoreFocus !== undefined + ? options.restoreFocus + : entry.context?.restoreFocusOnClose !== false; + + return entry.popover.close({ restoreFocus }); + } + closeAllMoreMenus(options = {}) { const skipView = options?.skipView === true; @@ -405,12 +429,16 @@ export default class MoreMenuController { this.videoListView.closeAllMenus({ skipController: true }); } - const currentPopover = this.activePopover; - if (currentPopover && typeof currentPopover.close === "function") { - currentPopover.close(); - if (this.activePopover === currentPopover) { - this.activePopover = null; + const restoreFocus = options?.restoreFocus !== false; + + this.popoversByTrigger.forEach((entry) => { + if (entry?.popover && typeof entry.popover.close === "function") { + entry.popover.close({ restoreFocus }); } + }); + + if (this.activePopover) { + this.activePopover = null; } } @@ -440,14 +468,59 @@ export default class MoreMenuController { 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") || ""; - this.toggleMoreMenu({ trigger: button, key }); + const context = button.getAttribute("data-context") || "card"; + + let video = null; + let pointerInfo = null; + let playbackUrl = ""; + let playbackMagnet = ""; + + if (context === "modal") { + video = this.callbacks.getCurrentVideo(); + if (Array.isArray(video?.pointer) && video.pointer.length >= 2) { + pointerInfo = { pointer: video.pointer }; + } + playbackUrl = typeof video?.url === "string" ? video.url : ""; + playbackMagnet = (() => { + if (typeof video?.magnet === "string" && video.magnet) { + return video.magnet; + } + if (typeof video?.infoHash === "string" && video.infoHash) { + return video.infoHash; + } + return ""; + })(); + } + + if (!pointerInfo) { + const pointerType = button.getAttribute("data-pointer-type") || ""; + const pointerValue = button.getAttribute("data-pointer-value") || ""; + const pointerRelay = button.getAttribute("data-pointer-relay") || ""; + const pointerKey = button.getAttribute("data-pointer-key") || ""; + if (pointerType && pointerValue) { + pointerInfo = { + pointer: pointerRelay + ? [pointerType, pointerValue, pointerRelay] + : [pointerType, pointerValue], + key: pointerKey, + }; + } + } + + this.toggleMoreMenu({ + trigger: button, + video, + pointerInfo, + playbackUrl, + playbackMagnet, + context, + canManageBlacklist: this.callbacks.canCurrentUserManageBlacklist(), + designSystem: this.designSystem, + }); }); }); } diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 3dd8e066..d3a8b07b 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -150,6 +150,22 @@ export class VideoListView { : () => {}, closeAllMenus: typeof utils.closeAllMenus === "function" ? utils.closeAllMenus : () => {}, + requestMoreMenu: + typeof utils.requestMoreMenu === "function" + ? utils.requestMoreMenu + : () => {}, + closeMoreMenu: + typeof utils.closeMoreMenu === "function" + ? utils.closeMoreMenu + : () => false, + requestSettingsMenu: + typeof utils.requestSettingsMenu === "function" + ? utils.requestSettingsMenu + : () => {}, + closeSettingsMenu: + typeof utils.closeSettingsMenu === "function" + ? utils.closeSettingsMenu + : () => false, }; this.renderedVideoIds = new Set(); @@ -504,6 +520,38 @@ export class VideoListView { }); }; + videoCard.onRequestMoreMenu = (detail = {}) => { + if (typeof this.utils.requestMoreMenu !== "function") { + return; + } + const payload = { + ...detail, + video: detail.video || video, + pointerInfo: detail.pointerInfo || pointerInfo, + }; + this.utils.requestMoreMenu(payload); + }; + + videoCard.onCloseMoreMenu = (detail = {}) => { + if (typeof this.utils.closeMoreMenu === "function") { + return this.utils.closeMoreMenu(detail); + } + return false; + }; + + videoCard.onRequestSettingsMenu = (detail = {}) => { + if (typeof this.utils.requestSettingsMenu === "function") { + this.utils.requestSettingsMenu(detail); + } + }; + + videoCard.onCloseSettingsMenu = (detail = {}) => { + if (typeof this.utils.closeSettingsMenu === "function") { + return this.utils.closeSettingsMenu(detail); + } + return false; + }; + const cardEl = videoCard.getRoot(); if (!cardEl) { return;