mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Two bugs in the #26 wiring surfaced live: - The ⋯ menu lazy-import used "../../viewCountChart.js" which resolves one level too high (dist/viewCountChart.js → 404 "Failed to fetch dynamically imported module"). Corrected to "../viewCountChart.js" (js/ui → js/viewCountChart.js). - The video-list context path called handleMoreMenuAction without the video, so view-stats hit "No video selected". Forward detail.video. Also make the popularity modal single-instance: close any open one (proper subscription/listener cleanup) before opening a new one, so the two menu paths firing for one click can't stack duplicates or leak a live subscription. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1623 lines
50 KiB
JavaScript
1623 lines
50 KiB
JavaScript
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 {
|
||
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),
|
||
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();
|
||
|
||
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,
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
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": {
|
||
const targetVideo =
|
||
video || (context === "modal" ? currentVideo : null) || currentVideo;
|
||
if (!targetVideo) {
|
||
this.callbacks.showError("No video selected.");
|
||
break;
|
||
}
|
||
// Dismiss the menu, then open the public popularity chart (lazy-loaded).
|
||
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 "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;
|
||
}
|
||
|
||
await this.callbacks.handleEventDetailsAction({
|
||
eventId: dataset.eventId,
|
||
video: targetVideo,
|
||
});
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
}
|