Files
bitvid/js/ui/moreMenuController.js
T
thePR0M3TH3ANandClaude Opus 4.8 f4fe4982bd playlists(#37): "Add to playlist" create/edit flow + enable FEATURE_PLAYLISTS
The create path — makes playlists actually usable end-to-end.
- js/ui/playlistPicker.js: an "Add to playlist" dialog (modeled on confirmDialog)
  listing the signed-in user's playlists as checkboxes (checked when the video is
  already in them) + a "Create new playlist" input. On Save it diffs the
  checkboxes and publishes only changed playlists (add/remove the video) plus any
  new one, via the facade (active signer). Reads the current pubkey from
  nostrClient, so callers pass only the video coordinate.
- videoMenuRenderers.js: "Add to playlist" item in the video ⋯ menu, gated by a
  new canManagePlaylists param.
- moreMenuController.js: computes canManagePlaylists (FEATURE_PLAYLISTS && logged
  in), threads it into the panel context, and adds the add-to-playlist handler
  (resolves the video → coordinate via buildVideoAddressPointer, closes the
  popover, opens the picker; success/error routed to the app's toasts).
- constants.js: FEATURE_PLAYLISTS flipped ON now that the core loop (create /
  add / remove / channel section / playlist view) works. Rollback: set false.

Owner controls on the playlist view (rename / drag-reorder / hard-delete) are a
follow-up; removing every video empties a playlist (drops from the listing).
Syntax + lint + build clean; more-menu-controller tests 9/9.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 13:57:09 -04:00

1692 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { normalizeDesignSystemContext } from "../designSystem.js";
import logger, { userLogger } from "../utils/logger.js";
import moderationService from "../services/moderationService.js";
import {
createVideoMoreMenuPanel,
} from "./components/videoMenuRenderers.js";
import createPopover from "./overlay/popoverEngine.js";
import { handleBlacklistEventAction } from "./moreMenu/blacklistEventAction.js";
import { openPlaylistPicker } from "./playlistPicker.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
import { FEATURE_PLAYLISTS } from "../constants.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./components/staticModalAccessibility.js";
const REPORT_CATEGORIES = Object.freeze([
{ value: "nudity", label: "Nudity or sexual content" },
{ value: "spam", label: "Spam or misleading" },
{ value: "illegal", label: "Illegal or dangerous content" },
{ value: "impersonation", label: "Impersonation or scam" },
{ value: "malware", label: "Malware or phishing" },
{ value: "profanity", label: "Profanity or hate speech" },
{ value: "other", label: "Other" },
]);
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 = {},
designSystem = null,
} = options;
this.document = doc;
this.accessControl = accessControl;
this.userBlocks = userBlocks;
this.subscriptions = subscriptions;
this.clipboard = clipboard;
this.isDevMode = Boolean(isDevMode);
this.designSystem = normalizeDesignSystemContext(designSystem);
this.moderationService = options?.moderationService || moderationService;
this.callbacks = {
getCurrentVideo: callbacks.getCurrentVideo || (() => null),
getVideoByEventId: callbacks.getVideoByEventId || (() => 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()),
handleEventDetailsAction:
callbacks.handleEventDetailsAction || (() => Promise.resolve()),
loadVideos: callbacks.loadVideos || (() => Promise.resolve()),
onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}),
};
this.callbacks.refreshAllVideoGrids =
typeof callbacks.refreshAllVideoGrids === "function"
? (options) => callbacks.refreshAllVideoGrids(options)
: (options = {}) =>
Promise.resolve(
this.callbacks.loadVideos(Boolean(options?.forceMainReload)),
).catch(() => {});
this.moreMenuGlobalHandlerBound = false;
this.videoListView = null;
this.videoModal = null;
this.boundVideoListShareListener = null;
this.boundVideoListContextListener = null;
this.boundVideoListBlacklistHandler = null;
this.popoversByTrigger = new Map();
this.activePopover = null;
this.activePopoverEntry = null;
this.reportModal = null;
this.reportModalPrepared = false;
this.reportForm = null;
this.reportStatusEl = null;
this.reportSubmitButton = null;
this.reportCancelButton = null;
this.reportCategoryInputs = [];
this.reportCleanupHandlers = [];
this.activeReportContext = null;
this.isReportSubmitting = false;
}
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, detail.video || null);
};
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);
this.destroyReportModal();
this.closeAllMoreMenus({ skipView: true });
this.popoversByTrigger.forEach((entry) => {
try {
entry?.popover?.destroy?.();
} catch (error) {
if (this.isDevMode) {
userLogger.warn("[MoreMenu] Failed to destroy popover:", error);
}
}
});
this.popoversByTrigger.clear();
this.activePopover = null;
this.moreMenuGlobalHandlerBound = false;
}
ensureGlobalMoreMenuHandlers() {
this.moreMenuGlobalHandlerBound = true;
}
buildReportModalMarkup() {
const options = REPORT_CATEGORIES.map((category, index) => {
const inputId = `reportCategory-${category.value}`;
const checkedAttr = index === 0 ? " checked" : "";
const requiredAttr = index === 0 ? " required" : "";
return `
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-border/60 bg-panel/80 p-3 text-sm" for="${inputId}">
<input
id="${inputId}"
type="radio"
name="report-category"
value="${category.value}"
class="h-4 w-4"
${checkedAttr}${requiredAttr}
/>
<span>${category.label}</span>
</label>
`;
}).join("");
return `
<div class="bv-modal-backdrop" data-report-dismiss></div>
<div class="modal-sheet w-full max-w-md space-y-4" role="document">
<form data-report-form class="modal-body space-y-4">
<header class="space-y-2">
<h2 id="reportModalTitle" class="text-lg font-semibold text-text">Report video</h2>
<p id="reportModalDescription" class="text-sm text-muted">
Choose the category that best describes the issue. Reports from people you follow help improve recommendations.
</p>
</header>
<fieldset class="space-y-2">
<legend class="text-sm font-semibold text-text">Category</legend>
<div class="space-y-2" role="radiogroup" aria-labelledby="reportModalTitle">
${options}
</div>
</fieldset>
<p class="hidden text-sm" data-report-status role="status"></p>
<div class="flex justify-end gap-3">
<button type="button" class="btn-ghost focus-ring" data-report-cancel>Cancel</button>
<button type="submit" class="btn focus-ring" data-report-submit>Submit report</button>
</div>
</form>
</div>
`;
}
ensureReportModal() {
if (this.reportModal && this.reportModalPrepared) {
return this.reportModal;
}
const doc = this.document;
if (!doc) {
return null;
}
const container = doc.getElementById("modalContainer") || doc.body || null;
if (!container) {
return null;
}
let modal = container.querySelector("#reportModal");
if (!modal) {
modal = doc.createElement("div");
modal.id = "reportModal";
modal.className = "bv-modal hidden items-center justify-center";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "reportModalTitle");
modal.setAttribute("aria-describedby", "reportModalDescription");
modal.innerHTML = this.buildReportModalMarkup();
container.appendChild(modal);
}
this.reportModal = modal;
this.reportForm = modal.querySelector("[data-report-form]");
this.reportStatusEl = modal.querySelector("[data-report-status]");
this.reportSubmitButton = modal.querySelector("[data-report-submit]");
this.reportCancelButton = modal.querySelector("[data-report-cancel]");
this.reportCategoryInputs = Array.from(
modal.querySelectorAll("input[name='report-category']"),
);
if (!this.reportModalPrepared) {
if (this.reportForm) {
const submitHandler = (event) => this.handleReportSubmit(event);
this.reportForm.addEventListener("submit", submitHandler);
this.reportCleanupHandlers.push(() => {
this.reportForm?.removeEventListener("submit", submitHandler);
});
}
if (this.reportCancelButton) {
const cancelHandler = (event) => {
event?.preventDefault?.();
this.closeReportModal();
};
this.reportCancelButton.addEventListener("click", cancelHandler);
this.reportCleanupHandlers.push(() => {
this.reportCancelButton?.removeEventListener("click", cancelHandler);
});
}
const backdrop = modal.querySelector("[data-report-dismiss]");
if (backdrop) {
const backdropHandler = (event) => {
event?.preventDefault?.();
this.closeReportModal();
};
backdrop.addEventListener("click", backdropHandler);
this.reportCleanupHandlers.push(() => {
backdrop.removeEventListener("click", backdropHandler);
});
}
prepareStaticModal({ root: modal, document: doc });
this.reportModalPrepared = true;
}
return modal;
}
openReportModal({
eventId,
authorPubkey = "",
relayHint = "",
trigger = null,
} = {}) {
const modal = this.ensureReportModal();
if (!modal) {
this.callbacks.showError(
"Reporting is unavailable right now. Please try again later.",
);
return false;
}
const normalizedEventId =
typeof eventId === "string" ? eventId.trim() : "";
const normalizedAuthor =
typeof authorPubkey === "string" ? authorPubkey.trim() : "";
const normalizedRelayHint =
typeof relayHint === "string" ? relayHint.trim() : "";
this.activeReportContext = {
eventId: normalizedEventId,
authorPubkey: normalizedAuthor,
relayHint: normalizedRelayHint,
};
this.isReportSubmitting = false;
this.setReportModalBusy(false);
this.showReportStatus("");
if (Array.isArray(this.reportCategoryInputs) && this.reportCategoryInputs.length) {
this.reportCategoryInputs.forEach((input, index) => {
if (!input) {
return;
}
try {
input.checked = index === 0;
input.disabled = false;
} catch (_) {
/* noop */
}
});
}
const opened = openStaticModal(modal, {
triggerElement:
trigger && typeof trigger.focus === "function" ? trigger : null,
document: this.document,
});
if (opened && this.reportCategoryInputs.length > 0) {
const firstInput = this.reportCategoryInputs[0];
try {
firstInput.focus();
} catch (_) {
/* noop */
}
}
try {
const service = this.moderationService || moderationService;
if (service && typeof service.refreshViewerFromClient === "function") {
service.refreshViewerFromClient();
}
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh moderation context before reporting:",
error,
);
}
}
return opened;
}
closeReportModal() {
if (!this.reportModal) {
return false;
}
const result = closeStaticModal(this.reportModal, { document: this.document });
this.activeReportContext = null;
this.isReportSubmitting = false;
this.showReportStatus("");
return result;
}
setReportModalBusy(isBusy) {
const disabled = Boolean(isBusy);
this.isReportSubmitting = disabled;
if (Array.isArray(this.reportCategoryInputs)) {
this.reportCategoryInputs.forEach((input) => {
if (input && typeof input.disabled === "boolean") {
input.disabled = disabled;
}
});
}
if (this.reportSubmitButton && typeof this.reportSubmitButton.disabled === "boolean") {
this.reportSubmitButton.disabled = disabled;
}
if (this.reportCancelButton && typeof this.reportCancelButton.disabled === "boolean") {
this.reportCancelButton.disabled = disabled;
}
}
showReportStatus(message, { variant = "info" } = {}) {
if (!this.reportStatusEl) {
return;
}
const trimmed = typeof message === "string" ? message.trim() : "";
if (!trimmed) {
this.reportStatusEl.classList.add("hidden");
this.reportStatusEl.textContent = "";
if (this.reportStatusEl.dataset.state) {
delete this.reportStatusEl.dataset.state;
}
return;
}
this.reportStatusEl.classList.remove("hidden");
this.reportStatusEl.textContent = trimmed;
this.reportStatusEl.dataset.state = variant;
}
showReportError(message) {
this.showReportStatus(message, { variant: "error" });
}
async handleReportSubmit(event) {
if (event && typeof event.preventDefault === "function") {
event.preventDefault();
}
if (this.isReportSubmitting) {
return;
}
const context = this.activeReportContext || {};
const eventId = typeof context.eventId === "string" ? context.eventId : "";
if (!eventId) {
this.showReportError("Unable to determine which video to report.");
return;
}
let category = "";
if (Array.isArray(this.reportCategoryInputs)) {
const selected = this.reportCategoryInputs.find((input) => input && input.checked);
if (selected && typeof selected.value === "string") {
category = selected.value.trim().toLowerCase();
}
}
if (!category) {
this.showReportError("Please choose a category before submitting.");
return;
}
const targetPubkey =
typeof context.authorPubkey === "string"
? context.authorPubkey.trim()
: "";
if (!targetPubkey) {
this.showReportError("Unable to determine which creator to report.");
return;
}
const viewerPubkey = this.callbacks.getCurrentUserPubkey?.();
if (!viewerPubkey) {
this.closeReportModal();
this.callbacks.showError("Connect a Nostr account before reporting videos.");
return;
}
this.setReportModalBusy(true);
this.showReportStatus("Sending report…", { variant: "info" });
const service = this.moderationService || moderationService;
try {
if (!service || typeof service.submitReport !== "function") {
const svcError = new Error("service-unavailable");
svcError.code = "service-unavailable";
throw svcError;
}
await service.submitReport({
eventId,
type: category,
targetPubkey,
relayHint:
typeof context.relayHint === "string"
? context.relayHint.trim()
: "",
});
this.callbacks.showSuccess(
"Report submitted. Thank you for keeping bitvid safe.",
);
this.closeReportModal();
} catch (error) {
logger.user.error("[MoreMenu] Failed to publish report:", error);
const code = error?.code || "unknown";
let message = "Unable to send your report. Please try again.";
if (code === "nostr-extension-missing") {
message = "A Nostr extension is required to report videos.";
} else if (code === "extension-permission-denied") {
message = "Approve the permission request in your Nostr extension and try again.";
} else if (code === "viewer-not-logged-in") {
message = "Connect a Nostr account before reporting videos.";
} else if (code === "service-unavailable") {
message = "Reporting is unavailable right now. Please try again later.";
} else if (code === "invalid-target-pubkey") {
message = "Unable to determine which creator to report.";
}
this.showReportError(message);
} finally {
this.setReportModalBusy(false);
}
}
destroyReportModal() {
if (Array.isArray(this.reportCleanupHandlers) && this.reportCleanupHandlers.length) {
for (const cleanup of this.reportCleanupHandlers) {
try {
if (typeof cleanup === "function") {
cleanup();
}
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to teardown report modal listener:",
error,
);
}
}
}
}
this.reportCleanupHandlers = [];
this.reportModalPrepared = false;
this.reportForm = null;
this.reportStatusEl = null;
this.reportSubmitButton = null;
this.reportCancelButton = null;
this.reportCategoryInputs = [];
this.activeReportContext = null;
this.isReportSubmitting = false;
if (this.reportModal) {
try {
closeStaticModal(this.reportModal, { document: this.document });
} catch (_) {
/* noop */
}
}
this.reportModal = null;
}
getHTMLElementConstructor(documentRef) {
return (
documentRef?.defaultView?.HTMLElement ||
(typeof HTMLElement !== "undefined" ? HTMLElement : null)
);
}
setButtonVisibility(button, visible) {
if (!button) {
return;
}
if (visible) {
button.removeAttribute("hidden");
button.setAttribute("aria-hidden", "false");
} else {
button.setAttribute("hidden", "");
button.setAttribute("aria-hidden", "true");
}
}
syncMuteMenuButtons(panel, entry = {}) {
if (!panel || typeof panel.querySelector !== "function") {
return;
}
const muteButton = panel.querySelector('button[data-action="mute-author"]');
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
if (!muteButton && !unmuteButton) {
return;
}
const contextVideo = entry?.context?.video || null;
let authorCandidate = contextVideo?.pubkey || "";
if (!authorCandidate) {
if (muteButton?.dataset?.author) {
authorCandidate = muteButton.dataset.author;
} else if (unmuteButton?.dataset?.author) {
authorCandidate = unmuteButton.dataset.author;
}
}
const trimmed = typeof authorCandidate === "string" ? authorCandidate.trim() : "";
let normalized = "";
if (trimmed) {
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
normalized = trimmed.toLowerCase();
} else if (trimmed.startsWith("npub1")) {
normalized = this.callbacks.safeDecodeNpub(trimmed) || "";
}
}
if (normalized) {
if (muteButton) {
muteButton.dataset.author = normalized;
}
if (unmuteButton) {
unmuteButton.dataset.author = normalized;
}
}
let isMuted = false;
if (normalized) {
try {
isMuted = this.moderationService.isAuthorMutedByViewer(normalized) === true;
} catch (error) {
if (this.isDevMode) {
userLogger.warn("[MoreMenu] Failed to resolve viewer mute state", error);
}
}
}
const hasTarget = Boolean(normalized);
this.setButtonVisibility(muteButton, hasTarget && !isMuted);
this.setButtonVisibility(unmuteButton, hasTarget && isMuted);
}
refreshActiveMuteButtons({ author = "" } = {}) {
if (!this.activePopoverEntry || !this.activePopover) {
return;
}
const panel =
typeof this.activePopover.getPanel === "function"
? this.activePopover.getPanel()
: null;
if (!panel) {
return;
}
if (author) {
const trimmed = author.trim();
if (trimmed) {
const muteButton = panel.querySelector('button[data-action="mute-author"]');
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
if (muteButton) {
muteButton.dataset.author = trimmed;
}
if (unmuteButton) {
unmuteButton.dataset.author = trimmed;
}
}
}
this.syncMuteMenuButtons(panel, this.activePopoverEntry);
}
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,
canManagePlaylists: entry.context.canManagePlaylists,
context: entry.context.context || "card",
designSystem: entry.context.designSystem || this.designSystem,
});
if (!panel) {
return null;
}
this.syncMuteMenuButtons(panel, entry);
const buttons = panel.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
const HTMLElementCtor = this.getHTMLElementConstructor(button?.ownerDocument);
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
return;
}
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const dataset = { ...button.dataset };
const action = dataset.action || "";
try {
await this.handleMoreMenuAction(action, dataset, entry.context.video);
} 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();
}
});
});
return panel;
};
}
decoratePopoverLifecycle(popover, trigger, entry) {
if (!popover) {
return null;
}
const originalOpen = popover.open?.bind(popover);
const originalClose = popover.close?.bind(popover);
const originalDestroy = popover.destroy?.bind(popover);
if (typeof originalOpen === "function") {
popover.open = async (...args) => {
const result = await originalOpen(...args);
if (result) {
this.activePopover = popover;
this.activePopoverEntry = entry;
const panel =
typeof popover.getPanel === "function" ? popover.getPanel() : null;
if (panel) {
this.syncMuteMenuButtons(panel, entry);
}
}
return result;
};
}
if (typeof originalClose === "function") {
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;
this.activePopoverEntry = 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;
};
}
if (typeof originalDestroy === "function") {
popover.destroy = (...args) => {
if (this.activePopover === popover) {
this.activePopover = null;
this.activePopoverEntry = null;
}
originalDestroy(...args);
if (trigger && this.popoversByTrigger.get(trigger) === entry) {
this.popoversByTrigger.delete(trigger);
}
};
}
return popover;
}
ensurePopoverForTrigger({ trigger }) {
if (!trigger) {
return null;
}
if (this.popoversByTrigger.has(trigger)) {
return this.popoversByTrigger.get(trigger);
}
const documentRef =
trigger?.ownerDocument ||
this.document ||
(typeof document !== "undefined" ? document : 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,
};
const render = this.createPopoverRender(entry);
const popover = createPopover(trigger, render, {
document: documentRef,
placement: "bottom-end",
});
const decorated = this.decoratePopoverLifecycle(popover, trigger, entry) || popover;
entry.popover = decorated;
this.popoversByTrigger.set(trigger, entry);
return entry;
}
async toggleMoreMenu(detail = {}) {
const {
trigger = null,
video = null,
pointerInfo = null,
playbackUrl = "",
playbackMagnet = "",
context = "card",
canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(),
canManagePlaylists = FEATURE_PLAYLISTS &&
!!this.callbacks.getCurrentUserPubkey?.(),
designSystem = null,
onAction = null,
onClose = null,
restoreFocusOnClose = true,
} = detail;
const entry = this.ensurePopoverForTrigger({ trigger });
if (!entry?.popover) {
return;
}
entry.context = {
...entry.context,
video,
pointerInfo,
playbackUrl,
playbackMagnet,
canManageBlacklist,
canManagePlaylists,
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 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;
const restoreFocus = options?.restoreFocus !== false;
const skipTrigger = options?.skipTrigger || null;
if (
!skipView &&
this.videoListView &&
typeof this.videoListView.closeAllMenus === "function"
) {
const viewOptions = {
skipController: true,
restoreFocus,
};
if (skipTrigger) {
viewOptions.skipTrigger = skipTrigger;
}
this.videoListView.closeAllMenus(viewOptions);
}
this.popoversByTrigger.forEach((entry) => {
if (!entry?.popover || typeof entry.popover.close !== "function") {
return;
}
if (skipTrigger && entry.trigger === skipTrigger) {
return;
}
entry.popover.close({ restoreFocus });
});
}
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.getHTMLElementConstructor(this.document);
buttons.forEach((button) => {
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
return;
}
if (button.dataset.moreMenuToggleBound === "true") {
return;
}
button.dataset.moreMenuToggleBound = "true";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
// Modal ⋯ context comes from `data-more-dropdown`, not `data-context`.
const context =
button.getAttribute("data-context") ||
button.getAttribute("data-more-dropdown") ||
"card";
let video = null;
let pointerInfo = null;
let playbackUrl = "";
let playbackMagnet = "";
if (context === "modal") {
video = this.callbacks.getCurrentVideo();
// currentVideo can be null on a deep link; fall back to activeVideo.
if (!video && this.videoModal?.activeVideo) {
video = this.videoModal.activeVideo;
}
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,
});
});
});
}
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 = {}, video = null) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
const currentVideo = this.callbacks.getCurrentVideo();
const resolveTargetAuthorHex = () => {
const candidates = [
dataset.author,
dataset.npub,
context === "modal" ? currentVideo?.pubkey : "",
currentVideo?.pubkey,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (!trimmed) {
continue;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.callbacks.safeDecodeNpub(trimmed);
if (decoded) {
return decoded;
}
}
}
return "";
};
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 "view-stats": {
let targetVideo = video || null;
if (!targetVideo && dataset?.eventId) {
targetVideo =
this.callbacks.getVideoByEventId(dataset.eventId) || null;
}
if (!targetVideo && context === "modal") {
targetVideo = currentVideo || null;
}
if (!targetVideo) {
this.callbacks.showError("No video selected.");
break;
}
try {
this.activePopover?.close?.();
} catch (error) {
// ignore
}
try {
const { openPopularityModal } = await import("../viewCountChart.js");
openPopularityModal({ video: targetVideo });
} catch (error) {
userLogger.warn("[MoreMenu] Failed to open popularity modal:", error);
this.callbacks.showError("Couldn’t open popularity.");
}
break;
}
case "add-to-playlist": {
let targetVideo = video || null;
if (!targetVideo && dataset?.eventId) {
targetVideo =
this.callbacks.getVideoByEventId(dataset.eventId) || null;
}
if (!targetVideo && context === "modal") {
targetVideo = currentVideo || null;
}
if (!targetVideo) {
this.callbacks.showError("No video selected.");
break;
}
const coordinate = buildVideoAddressPointer(targetVideo);
if (!coordinate) {
this.callbacks.showError("Couldn’t identify this video.");
break;
}
try {
this.activePopover?.close?.();
} catch (error) {
// ignore
}
openPlaylistPicker({
videoCoordinate: coordinate,
notify: (kind, message) =>
kind === "success"
? this.callbacks.showSuccess(message)
: this.callbacks.showError(message),
}).catch((error) => {
userLogger.warn("[MoreMenu] Add-to-playlist failed:", error);
});
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) {
userLogger.error("Failed to copy npub:", error);
this.callbacks.showError("Failed to copy the npub.");
}
break;
}
case "mute-author": {
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
if (!viewerPubkey) {
this.callbacks.showError("Please login to manage your mute list.");
break;
}
const targetHex = resolveTargetAuthorHex();
if (!targetHex) {
this.callbacks.showError("Unable to determine the creator to mute.");
break;
}
if (targetHex === viewerPubkey) {
this.callbacks.showError("You cannot mute yourself.");
break;
}
try {
const result = await this.userBlocks.addMute(targetHex, viewerPubkey);
if (result?.already) {
this.callbacks.showSuccess("You already muted this creator.");
} else {
this.callbacks.showSuccess(
"Creator muted. Their videos will be downranked in your feeds.",
);
}
try {
await this.callbacks.refreshAllVideoGrids({
reason: "viewer-mute-update",
forceMainReload: true,
});
} catch (refreshError) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh video grids after viewer mute update:",
refreshError,
);
}
}
this.refreshActiveMuteButtons({ author: targetHex });
} catch (error) {
switch (error?.code) {
case "invalid":
this.callbacks.showError("Unable to determine the creator to mute.");
break;
case "self":
this.callbacks.showError("You cannot mute yourself.");
break;
case "signer-missing":
this.callbacks.showError(
"A Nostr extension is required to update your mute list.",
);
break;
case "extension-encryption-permission-denied":
this.callbacks.showError(
"Allow your Nostr extension to sign events before muting creators.",
);
break;
default:
this.callbacks.showError(
"Failed to update your mute list. Please try again.",
);
break;
}
}
break;
}
case "unmute-author": {
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
if (!viewerPubkey) {
this.callbacks.showError("Please login to manage your mute list.");
break;
}
const targetHex = resolveTargetAuthorHex();
if (!targetHex) {
this.callbacks.showError("Unable to determine the creator to unmute.");
break;
}
try {
const result = await this.userBlocks.removeMute(targetHex, viewerPubkey);
if (result?.already) {
this.callbacks.showSuccess("This creator is not on your mute list.");
} else {
this.callbacks.showSuccess(
"Creator removed from your mute list.",
);
}
try {
await this.callbacks.refreshAllVideoGrids({
reason: "viewer-mute-update",
forceMainReload: true,
});
} catch (refreshError) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh video grids after viewer mute removal:",
refreshError,
);
}
}
this.refreshActiveMuteButtons({ author: targetHex });
} catch (error) {
switch (error?.code) {
case "signer-missing":
this.callbacks.showError(
"A Nostr extension is required to update your mute list.",
);
break;
case "extension-encryption-permission-denied":
this.callbacks.showError(
"Allow your Nostr extension to sign events before updating your mute list.",
);
break;
default:
this.callbacks.showError(
"Failed to update your mute list. Please try again.",
);
break;
}
}
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) {
userLogger.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.callbacks.refreshAllVideoGrids({
reason: "admin-blacklist-update",
forceMainReload: true,
});
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh video grids 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) {
userLogger.error("Failed to add creator to blacklist:", error);
this.callbacks.showError(
"Failed to update the blacklist. Please try again.",
);
}
break;
}
case "blacklist-event": {
await handleBlacklistEventAction({
accessControl: this.accessControl,
callbacks: this.callbacks,
dataset,
currentVideo,
});
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) {
userLogger.warn(
"[MoreMenu] Failed to refresh blocked list after update:",
error,
);
}
try {
await this.moderationService?.awaitUserBlockRefresh?.();
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Moderation sync failed after block update:",
error,
);
}
}
try {
await this.callbacks.refreshAllVideoGrids({
reason: "user-block-update",
forceMainReload: true,
});
} catch (error) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to refresh video grids after user block update:",
error,
);
}
}
} catch (error) {
userLogger.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."
: error?.code === "extension-encryption-permission-denied"
? "Your Nostr extension must allow encryption to update your block list."
: "Failed to update your block list. Please try again.";
this.callbacks.showError(message);
}
break;
}
case "report": {
const viewerPubkey = this.callbacks.getCurrentUserPubkey?.();
if (!viewerPubkey) {
this.callbacks.showError(
"Connect a Nostr account before reporting videos.",
);
break;
}
const eventId =
(typeof dataset.eventId === "string" ? dataset.eventId : "") ||
(context === "modal" && typeof currentVideo?.id === "string"
? currentVideo.id
: "") ||
(typeof currentVideo?.id === "string" ? currentVideo.id : "");
const normalizedEventId = eventId ? eventId.trim() : "";
if (!normalizedEventId) {
this.callbacks.showError(
"Unable to determine which video to report.",
);
break;
}
const authorPubkey =
(typeof dataset.author === "string" ? dataset.author : "") ||
(context === "modal" && typeof currentVideo?.pubkey === "string"
? currentVideo.pubkey
: "") ||
(typeof currentVideo?.pubkey === "string" ? currentVideo.pubkey : "");
const normalizedAuthor = authorPubkey ? authorPubkey.trim() : "";
if (!normalizedAuthor) {
this.callbacks.showError(
"Unable to determine which creator to report.",
);
break;
}
let relayHint =
(typeof dataset.pointerRelay === "string"
? dataset.pointerRelay
: "") || "";
if (relayHint) {
relayHint = relayHint.trim();
}
if (!relayHint && context === "modal") {
const pointer = Array.isArray(currentVideo?.pointer)
? currentVideo.pointer
: null;
const hintCandidate = pointer && pointer.length > 2 ? pointer[2] : "";
if (typeof hintCandidate === "string" && hintCandidate.trim()) {
relayHint = hintCandidate.trim();
}
}
this.openReportModal({
eventId: normalizedEventId,
authorPubkey: normalizedAuthor,
relayHint,
trigger: dataset.triggerElement || null,
});
break;
}
case "event-details": {
let targetVideo = video;
if (!targetVideo && context === "modal") {
targetVideo = currentVideo;
}
try {
this.activePopover?.close?.();
} catch (error) {
// ignore
}
await this.callbacks.handleEventDetailsAction({
eventId: dataset.eventId,
video: targetVideo,
});
break;
}
default:
break;
}
}
}