mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
650 lines
20 KiB
JavaScript
650 lines
20 KiB
JavaScript
import { normalizeDesignSystemContext } from "../designSystem.js";
|
|
import positionFloatingPanel from "./utils/positionFloatingPanel.js";
|
|
import { createFloatingPanelStyles } from "./utils/floatingPanelStyles.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
import {
|
|
getPopupOffsetPx,
|
|
getPopupViewportPaddingPx,
|
|
} from "../designSystem/metrics.js";
|
|
|
|
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.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()),
|
|
loadVideos: callbacks.loadVideos || (() => Promise.resolve()),
|
|
onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}),
|
|
};
|
|
|
|
this.moreMenuGlobalHandlerBound = false;
|
|
this.boundDocumentClick = null;
|
|
this.boundDocumentKeydown = null;
|
|
|
|
this.videoListView = null;
|
|
this.videoModal = null;
|
|
|
|
this.boundVideoListShareListener = null;
|
|
this.boundVideoListContextListener = null;
|
|
this.boundVideoListBlacklistHandler = null;
|
|
this.dropdownPositioners = new Map();
|
|
}
|
|
|
|
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);
|
|
|
|
if (this.document && this.boundDocumentClick) {
|
|
this.document.removeEventListener("click", this.boundDocumentClick);
|
|
}
|
|
if (this.document && this.boundDocumentKeydown) {
|
|
this.document.removeEventListener("keydown", this.boundDocumentKeydown);
|
|
}
|
|
|
|
this.boundDocumentClick = null;
|
|
this.boundDocumentKeydown = null;
|
|
this.moreMenuGlobalHandlerBound = false;
|
|
}
|
|
|
|
ensureGlobalMoreMenuHandlers() {
|
|
if (this.moreMenuGlobalHandlerBound) {
|
|
return;
|
|
}
|
|
|
|
if (!this.document) {
|
|
return;
|
|
}
|
|
|
|
this.moreMenuGlobalHandlerBound = true;
|
|
|
|
this.boundDocumentClick = (event) => {
|
|
const target = event?.target;
|
|
if (!target || typeof target.closest !== "function") {
|
|
this.closeAllMoreMenus();
|
|
return;
|
|
}
|
|
if (
|
|
target.closest("[data-more-menu-wrapper]") ||
|
|
target.closest("[data-more-menu]")
|
|
) {
|
|
return;
|
|
}
|
|
this.closeAllMoreMenus();
|
|
};
|
|
|
|
this.boundDocumentKeydown = (event) => {
|
|
if (event?.key === "Escape") {
|
|
this.closeAllMoreMenus();
|
|
}
|
|
};
|
|
|
|
this.document.addEventListener("click", this.boundDocumentClick);
|
|
this.document.addEventListener("keydown", this.boundDocumentKeydown);
|
|
}
|
|
|
|
closeAllMoreMenus() {
|
|
if (
|
|
this.videoListView &&
|
|
typeof this.videoListView.closeAllMenus === "function"
|
|
) {
|
|
this.videoListView.closeAllMenus();
|
|
}
|
|
|
|
if (!this.document || typeof this.document.querySelectorAll !== "function") {
|
|
return;
|
|
}
|
|
|
|
const HTMLElementCtor =
|
|
this.document?.defaultView?.HTMLElement ||
|
|
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
|
|
|
const menus = this.document.querySelectorAll("[data-more-menu]");
|
|
menus.forEach((menu) => {
|
|
if (!HTMLElementCtor || menu instanceof HTMLElementCtor) {
|
|
menu.dataset.state = "closed";
|
|
menu.setAttribute("aria-hidden", "true");
|
|
menu.hidden = true;
|
|
}
|
|
});
|
|
|
|
const buttons = this.document.querySelectorAll("[data-more-dropdown]");
|
|
buttons.forEach((button) => {
|
|
if (!HTMLElementCtor || button instanceof HTMLElementCtor) {
|
|
button.setAttribute("aria-expanded", "false");
|
|
}
|
|
});
|
|
}
|
|
|
|
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.document?.defaultView?.HTMLElement ||
|
|
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
|
|
|
buttons.forEach((button) => {
|
|
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
|
|
return;
|
|
}
|
|
if (button.dataset.moreMenuToggleBound === "true") {
|
|
return;
|
|
}
|
|
button.dataset.moreMenuToggleBound = "true";
|
|
button.setAttribute("aria-expanded", "false");
|
|
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const key = button.getAttribute("data-more-dropdown") || "";
|
|
const dropdown = this.document.getElementById(`moreDropdown-${key}`);
|
|
if (HTMLElementCtor && !(dropdown instanceof HTMLElementCtor)) {
|
|
return;
|
|
}
|
|
|
|
if (dropdown && !this.dropdownPositioners.has(dropdown)) {
|
|
const styles = createFloatingPanelStyles(dropdown);
|
|
const metricsDocument =
|
|
dropdown?.ownerDocument ||
|
|
button?.ownerDocument ||
|
|
this.document ||
|
|
(typeof document !== "undefined" ? document : null);
|
|
const offset = getPopupOffsetPx({ documentRef: metricsDocument });
|
|
const viewportPadding = getPopupViewportPaddingPx({
|
|
documentRef: metricsDocument,
|
|
});
|
|
const positioner = positionFloatingPanel(button, dropdown, {
|
|
placement: "bottom",
|
|
alignment: "end",
|
|
offset,
|
|
viewportPadding,
|
|
styles,
|
|
});
|
|
this.dropdownPositioners.set(dropdown, positioner);
|
|
}
|
|
|
|
if (dropdown && !dropdown.dataset.state) {
|
|
const isHidden =
|
|
dropdown.hasAttribute("hidden") ||
|
|
dropdown.getAttribute("aria-hidden") === "true";
|
|
dropdown.dataset.state = isHidden ? "closed" : "open";
|
|
}
|
|
|
|
if (dropdown && dropdown.dataset.state !== "open") {
|
|
dropdown.hidden = true;
|
|
dropdown.setAttribute("aria-hidden", "true");
|
|
}
|
|
|
|
const willOpen = dropdown?.dataset.state !== "open";
|
|
this.closeAllMoreMenus();
|
|
|
|
if (willOpen) {
|
|
dropdown.hidden = false;
|
|
dropdown.dataset.state = "open";
|
|
dropdown.setAttribute("aria-hidden", "false");
|
|
button.setAttribute("aria-expanded", "true");
|
|
const positioner = this.dropdownPositioners.get(dropdown);
|
|
positioner?.update();
|
|
}
|
|
});
|
|
});
|
|
|
|
const actionButtons = container.querySelectorAll(
|
|
"[data-more-menu] button[data-action]",
|
|
);
|
|
actionButtons.forEach((button) => {
|
|
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
|
|
return;
|
|
}
|
|
if (button.dataset.moreMenuActionBound === "true") {
|
|
return;
|
|
}
|
|
button.dataset.moreMenuActionBound = "true";
|
|
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const { action } = button.dataset;
|
|
this.handleMoreMenuAction(action, button.dataset);
|
|
this.closeAllMoreMenus();
|
|
});
|
|
});
|
|
}
|
|
|
|
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 = {}) {
|
|
const normalized = typeof action === "string" ? action.trim() : "";
|
|
const context = dataset.context || "";
|
|
const currentVideo = this.callbacks.getCurrentVideo();
|
|
|
|
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 "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.subscriptions?.refreshActiveFeed?.({
|
|
reason: "admin-blacklist-update",
|
|
});
|
|
} catch (error) {
|
|
if (this.isDevMode) {
|
|
userLogger.warn(
|
|
"[Subscriptions] Failed to refresh 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,
|
|
);
|
|
}
|
|
|
|
await this.callbacks.loadVideos();
|
|
try {
|
|
await this.subscriptions?.refreshActiveFeed?.({
|
|
reason: "user-block-update",
|
|
});
|
|
} catch (error) {
|
|
if (this.isDevMode) {
|
|
userLogger.warn(
|
|
"[Subscriptions] Failed to refresh 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."
|
|
: "Failed to update your block list. Please try again.";
|
|
this.callbacks.showError(message);
|
|
}
|
|
break;
|
|
}
|
|
case "report": {
|
|
this.callbacks.showSuccess("Reporting coming soon.");
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|