mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
1611 lines
49 KiB
JavaScript
1611 lines
49 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);
|
|
};
|
|
|
|
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 "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.moderationService.addAuthorToViewerMuteList(
|
|
targetHex,
|
|
);
|
|
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 "viewer-not-logged-in":
|
|
this.callbacks.showError("Please login to manage your mute list.");
|
|
break;
|
|
case "invalid-target":
|
|
this.callbacks.showError("Unable to determine the creator to mute.");
|
|
break;
|
|
case "self":
|
|
this.callbacks.showError("You cannot mute yourself.");
|
|
break;
|
|
case "nostr-extension-missing":
|
|
this.callbacks.showError(
|
|
"A Nostr extension is required to update your mute list.",
|
|
);
|
|
break;
|
|
case "extension-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.moderationService.removeAuthorFromViewerMuteList(
|
|
targetHex,
|
|
);
|
|
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 "viewer-not-logged-in":
|
|
this.callbacks.showError("Please login to manage your mute list.");
|
|
break;
|
|
case "nostr-extension-missing":
|
|
this.callbacks.showError(
|
|
"A Nostr extension is required to update your mute list.",
|
|
);
|
|
break;
|
|
case "extension-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;
|
|
}
|
|
}
|
|
}
|