Refactor video menus to overlay popovers

This commit is contained in:
thePR0M3TH3AN committed 2025-10-16 11:33:31 -04:00
1 parent 5928ede539
commit 7ef16cd004
6 files changed
+975 -529

No files matched your search

+190
View File
@@ -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();
+30
View File
@@ -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);
+170 -409
View File
@@ -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
+344
View File
@@ -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;
}
+193 -120
View File
@@ -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,
});
});
});
}
+48
View File
@@ -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;