Files
bitvid/js/ui/moreMenuController.js
T
thePR0M3TH3ANandClaude Opus 4.8 3e57ab2ea9 [moderation] #25: per-video admin block list (hide a single video, not the author)
Admin moderation was author/community-level only. Admins (any editor — same gate as
the author blacklist) can now hide a SINGLE offending video.

- Record: a published kind-30000 NIP-51 list under d-tag bitvid:admin:event-blacklist,
  carrying blocked event ids as `e` tags (NOTE_TYPES.ADMIN_EVENT_BLACKLIST schema).
  Loaded/persisted through the existing admin-list machinery (adminListStore) and cached
  like whitelist/blacklist.
- accessControl: eventBlacklist Set + getEventBlacklist/isEventBlacklisted/
  addToEventBlacklist/removeFromEventBlacklist (editor-gated; normalizes hex or
  nevent/note) + onEventBlacklistChange.
- Render filter already existed (app.blacklistedEventIds, honored by every feed/grid/
  search/playback path) but was fed only by the static ADMIN_INITIAL_EVENT_BLACKLIST
  config. app._rebuildBlacklistedEventIds() now merges that static base with the dynamic
  admin list, and an onEventBlacklistChange listener rebuilds + refreshes grids on change.
- UI: a "Block this video" item in the per-video ⋯ menu (editor-only), handled by
  handleBlacklistEventAction (js/ui/moreMenu/blacklistEventAction.js).

Helpers extracted to js/adminEventBlacklistHelpers.js to keep adminListStore /
accessControl / moreMenuController under their size caps. Tests:
tests/admin-event-blacklist.test.mjs (9). Build + lint clean; admin/access/moderation
suites 56/56.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:34 -04:00

1651 lines
50 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 {
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,
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(),
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,
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 "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;
}
}
}