Merge pull request #377 from PR0M3TH3AN/codex/refactor-more-menu-into-controller-yeuyxk

Refactor more menu logic into controller
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-09 11:56:58 -04:00
commit c081d950b2
3 files changed
+832 -410

No files matched your search

+73 -410
View File
@@ -63,6 +63,7 @@ import { UploadModal } from "./ui/components/UploadModal.js";
import { EditModal } from "./ui/components/EditModal.js";
import { RevertModal } from "./ui/components/RevertModal.js";
import { VideoListView } from "./ui/views/VideoListView.js";
import MoreMenuController from "./ui/moreMenuController.js";
import ProfileModalController from "./ui/profileModalController.js";
import ZapController from "./ui/zapController.js";
import { MediaLoader } from "./utils/mediaLoader.js";
@@ -182,9 +183,7 @@ class Application {
this.videoListViewEditHandler = null;
this.videoListViewRevertHandler = null;
this.videoListViewDeleteHandler = null;
this.videoListViewBlacklistHandler = null;
this.boundVideoListShareListener = null;
this.boundVideoListContextListener = null;
this.moreMenuController = null;
this.latestFeedMetadata = null;
this.nostrService = services.nostrService || nostrService;
@@ -619,6 +618,53 @@ class Application {
this.boundVideoModalZapWalletHandler
);
this.moreMenuController = new MoreMenuController({
document,
accessControl,
userBlocks,
subscriptions,
clipboard:
typeof navigator !== "undefined" && navigator?.clipboard
? navigator.clipboard
: null,
isDevMode,
callbacks: {
getCurrentVideo: () => this.currentVideo,
getCurrentUserNpub: () => this.getCurrentUserNpub(),
getCurrentUserPubkey: () => this.pubkey,
canCurrentUserManageBlacklist: () =>
this.canCurrentUserManageBlacklist(),
openCreatorChannel: () => this.openCreatorChannel(),
goToProfile: (author) => this.goToProfile(author),
showError: (message) => this.showError(message),
showSuccess: (message) => this.showSuccess(message),
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
safeDecodeNpub: (npub) => this.safeDecodeNpub(npub),
buildShareUrlFromEventId: (eventId) =>
this.buildShareUrlFromEventId(eventId),
handleRemoveHistoryAction: (payload) =>
this.handleRemoveHistoryAction(payload),
handleRepostAction: (payload) => this.handleRepostAction(payload),
handleMirrorAction: (payload) => this.handleMirrorAction(payload),
handleEnsurePresenceAction: (payload) =>
this.handleEnsurePresenceAction(payload),
loadVideos: () => this.loadVideos(),
onUserBlocksUpdated: () => {
if (this.profileController) {
try {
this.profileController.populateBlockedList();
} catch (error) {
console.warn(
"[profileModal] Failed to refresh blocked list after update:",
error,
);
}
}
},
},
});
this.moreMenuController.setVideoModal(this.videoModal);
// Hide/Show Subscriptions Link
this.subscriptionsLink = null;
@@ -647,9 +693,6 @@ class Application {
this.videoSubscription = subscription || null;
}
);
this.moreMenuGlobalHandlerBound = false;
this.boundMoreMenuDocumentClick = null;
this.boundMoreMenuDocumentKeydown = null;
this.boundNwcSettingsToastHandler = null;
Object.defineProperty(this, "savedProfiles", {
configurable: false,
@@ -812,41 +855,9 @@ class Application {
};
this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler);
this.videoListViewBlacklistHandler = ({ video, dataset }) => {
const detail = {
...(dataset || {}),
author: dataset?.author || video?.pubkey || "",
};
this.handleMoreMenuAction("blacklist-author", detail);
};
this.videoListView.setBlacklistHandler(this.videoListViewBlacklistHandler);
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",
};
this.handleMoreMenuAction(detail.action || "copy-link", dataset);
};
this.videoListView.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);
};
this.videoListView.addEventListener(
"video:context-action",
this.boundVideoListContextListener
);
if (this.moreMenuController) {
this.moreMenuController.attachVideoListView(this.videoListView);
}
// NEW: reference to the login modal's close button
this.closeLoginModalBtn =
@@ -4379,127 +4390,27 @@ class Application {
}
ensureGlobalMoreMenuHandlers() {
if (this.moreMenuGlobalHandlerBound) {
return;
if (this.moreMenuController) {
this.moreMenuController.ensureGlobalMoreMenuHandlers();
}
this.moreMenuGlobalHandlerBound = true;
this.boundMoreMenuDocumentClick = (event) => {
const target = event.target;
if (
target instanceof HTMLElement &&
(target.closest("[data-more-menu-wrapper]") ||
target.closest("[data-more-menu]"))
) {
return;
}
this.closeAllMoreMenus();
};
this.boundMoreMenuDocumentKeydown = (event) => {
if (event.key === "Escape") {
this.closeAllMoreMenus();
}
};
document.addEventListener("click", this.boundMoreMenuDocumentClick);
document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown);
}
closeAllMoreMenus() {
if (this.videoListView) {
this.videoListView.closeAllMenus();
if (this.moreMenuController) {
this.moreMenuController.closeAllMoreMenus();
}
const menus = document.querySelectorAll("[data-more-menu]");
menus.forEach((menu) => {
if (menu instanceof HTMLElement) {
menu.classList.add("hidden");
}
});
const buttons = document.querySelectorAll("[data-more-dropdown]");
buttons.forEach((btn) => {
if (btn instanceof HTMLElement) {
btn.setAttribute("aria-expanded", "false");
}
});
}
attachMoreMenuHandlers(container) {
if (!container || typeof container.querySelectorAll !== "function") {
return;
if (this.moreMenuController) {
this.moreMenuController.attachMoreMenuHandlers(container);
}
const buttons = container.querySelectorAll("[data-more-dropdown]");
if (!buttons.length) {
return;
}
this.ensureGlobalMoreMenuHandlers();
buttons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
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 = document.getElementById(`moreDropdown-${key}`);
if (!(dropdown instanceof HTMLElement)) {
return;
}
const willOpen = dropdown.classList.contains("hidden");
this.closeAllMoreMenus();
if (willOpen) {
dropdown.classList.remove("hidden");
button.setAttribute("aria-expanded", "true");
}
});
});
const actionButtons = container.querySelectorAll(
"[data-more-menu] button[data-action]"
);
actionButtons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
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) {
return;
if (this.moreMenuController) {
this.moreMenuController.syncModalMoreMenuData();
}
this.videoModal.syncMoreMenuData({
currentVideo: this.currentVideo,
canManageBlacklist: this.canCurrentUserManageBlacklist(),
});
}
derivePointerFromDataset(dataset = {}, context = "") {
@@ -4534,253 +4445,11 @@ class Application {
}
async handleMoreMenuAction(action, dataset = {}) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
switch (normalized) {
case "open-channel": {
if (context === "modal") {
this.openCreatorChannel();
break;
}
const author =
dataset.author || (this.currentVideo ? this.currentVideo.pubkey : "");
if (author) {
this.goToProfile(author);
} else {
this.showError("No creator info available.");
}
break;
}
case "copy-link": {
const eventId =
dataset.eventId ||
(context === "modal" && this.currentVideo ? this.currentVideo.id : "");
if (!eventId) {
this.showError("Could not generate link.");
break;
}
const shareUrl = this.buildShareUrlFromEventId(eventId);
if (!shareUrl) {
this.showError("Could not generate link.");
break;
}
navigator.clipboard
.writeText(shareUrl)
.then(() => this.showSuccess("Video link copied to clipboard!"))
.catch(() => this.showError("Failed to copy the link."));
break;
}
case "remove-history": {
await this.handleRemoveHistoryAction(dataset);
break;
}
case "repost-event": {
await this.handleRepostAction(dataset);
break;
}
case "mirror-video": {
await this.handleMirrorAction(dataset);
break;
}
case "ensure-presence": {
await this.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.safeEncodeNpub(authorCandidate);
const valueToCopy = explicitNpub || fallbackNpub;
if (!valueToCopy) {
this.showError("No npub available to copy.");
break;
}
try {
await navigator.clipboard.writeText(valueToCopy);
this.showSuccess("Channel npub copied to clipboard!");
} catch (error) {
console.error("Failed to copy npub:", error);
this.showError("Failed to copy the npub.");
}
break;
}
case "blacklist-author": {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub) {
this.showError("Please login as a moderator to manage the blacklist.");
break;
}
try {
await accessControl.ensureReady();
} catch (error) {
console.warn("Failed to refresh moderation state before blacklisting:", error);
}
if (!accessControl.canEditAdminLists(actorNpub)) {
this.showError("Only moderators can manage the blacklist.");
break;
}
let author = dataset.author || "";
if (!author && context === "modal" && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
if (!author && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
const explicitNpub =
typeof dataset.npub === "string" && dataset.npub.trim()
? dataset.npub.trim()
: "";
const targetNpub = explicitNpub || this.safeEncodeNpub(author);
if (!targetNpub) {
this.showError("Unable to determine the creator npub.");
break;
}
try {
const result = await accessControl.addToBlacklist(
actorNpub,
targetNpub
);
if (result?.ok) {
this.showSuccess("Creator added to the blacklist.");
subscriptions
.refreshActiveFeed({ reason: "admin-blacklist-update" })
.catch((error) => {
if (isDevMode) {
console.warn(
"[Subscriptions] Failed to refresh after blacklist update:",
error
);
}
});
} else {
const code = result?.error || "unknown";
switch (code) {
case "self":
this.showError("You cannot blacklist yourself.");
break;
case "immutable":
this.showError(
"Moderators cannot blacklist the super admin or fellow moderators."
);
break;
case "invalid npub":
this.showError("Unable to blacklist this creator.");
break;
case "forbidden":
this.showError("Only moderators can manage the blacklist.");
break;
default:
this.showError(
"Failed to update the blacklist. Please try again."
);
break;
}
}
} catch (error) {
console.error("Failed to add creator to blacklist:", error);
this.showError("Failed to update the blacklist. Please try again.");
}
break;
}
case "block-author": {
if (!this.pubkey) {
this.showError("Please login to manage your block list.");
break;
}
const authorCandidate =
dataset.author ||
(this.currentVideo && this.currentVideo.pubkey) ||
"";
const trimmed =
typeof authorCandidate === "string" ? authorCandidate.trim() : "";
if (!trimmed) {
this.showError("Unable to determine the creator to block.");
break;
}
let normalizedHex = "";
if (trimmed.startsWith("npub1")) {
normalizedHex = this.safeDecodeNpub(trimmed) || "";
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
normalizedHex = trimmed.toLowerCase();
}
if (!normalizedHex) {
this.showError("Unable to determine the creator to block.");
break;
}
if (normalizedHex === this.pubkey) {
this.showError("You cannot block yourself.");
break;
}
try {
await userBlocks.ensureLoaded(this.pubkey);
if (userBlocks.isBlocked(normalizedHex)) {
this.showSuccess("You already blocked this creator.");
} else {
await userBlocks.addBlock(normalizedHex, this.pubkey);
this.showSuccess(
"Creator blocked. You won't see their videos anymore."
);
}
if (this.profileController) {
try {
this.profileController.populateBlockedList();
} catch (error) {
console.warn(
"[profileModal] Failed to refresh blocked list after update:",
error,
);
}
}
await this.loadVideos();
subscriptions
.refreshActiveFeed({ reason: "user-block-update" })
.catch((error) => {
if (isDevMode) {
console.warn(
"[Subscriptions] Failed to refresh after user block update:",
error
);
}
});
} catch (error) {
console.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.showError(message);
}
break;
}
case "report": {
this.showSuccess("Reporting coming soon.");
break;
}
default:
break;
if (!this.moreMenuController) {
return;
}
return this.moreMenuController.handleMoreMenuAction(action, dataset);
}
async handleRepostAction(dataset = {}) {
@@ -6981,35 +6650,29 @@ class Application {
}
if (this.videoListView) {
if (this.boundVideoListShareListener) {
this.videoListView.removeEventListener(
"video:share",
this.boundVideoListShareListener
);
this.boundVideoListShareListener = null;
}
if (this.boundVideoListContextListener) {
this.videoListView.removeEventListener(
"video:context-action",
this.boundVideoListContextListener
);
this.boundVideoListContextListener = null;
if (this.moreMenuController) {
this.moreMenuController.detachVideoListView();
}
this.videoListView.setPlaybackHandler(null);
this.videoListView.setEditHandler(null);
this.videoListView.setRevertHandler(null);
this.videoListView.setDeleteHandler(null);
this.videoListView.setBlacklistHandler(null);
this.videoListViewPlaybackHandler = null;
this.videoListViewEditHandler = null;
this.videoListViewRevertHandler = null;
this.videoListViewDeleteHandler = null;
this.videoListViewBlacklistHandler = null;
try {
this.videoListView.destroy();
} catch (error) {
console.warn("[Application] Failed to destroy VideoListView:", error);
}
this.videoListView = null;
}
if (this.moreMenuController) {
this.moreMenuController.setVideoModal(null);
this.moreMenuController.destroy();
this.moreMenuController = null;
}
if (this.profileController) {
+598
View File
@@ -0,0 +1,598 @@
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 = {},
} = options;
this.document = doc;
this.accessControl = accessControl;
this.userBlocks = userBlocks;
this.subscriptions = subscriptions;
this.clipboard = clipboard;
this.isDevMode = Boolean(isDevMode);
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;
}
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.classList.add("hidden");
}
});
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;
}
const willOpen = dropdown.classList.contains("hidden");
this.closeAllMoreMenus();
if (willOpen) {
dropdown.classList.remove("hidden");
button.setAttribute("aria-expanded", "true");
}
});
});
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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.warn(
"[Subscriptions] Failed to refresh after user block update:",
error,
);
}
}
} catch (error) {
console.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;
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import MoreMenuController from '../js/ui/moreMenuController.js';
test('copy-link action writes to clipboard and shows success', async () => {
let copied = null;
const clipboard = {
writeText: async (text) => {
copied = text;
},
};
const messages = { error: [], success: [] };
const controller = new MoreMenuController({
clipboard,
callbacks: {
buildShareUrlFromEventId: (eventId) => `https://example.com/watch/${eventId}`,
showError: (message) => messages.error.push(message),
showSuccess: (message) => messages.success.push(message),
getCurrentVideo: () => ({ id: 'fallback-id' }),
},
});
await controller.handleMoreMenuAction('copy-link', { eventId: 'abc123' });
await Promise.resolve();
assert.equal(copied, 'https://example.com/watch/abc123');
assert.deepEqual(messages.error, []);
assert.deepEqual(messages.success, ['Video link copied to clipboard!']);
});
test('blacklist-author requires moderator access and refreshes subscriptions', async () => {
const calls = [];
const accessControl = {
ensureReady: async () => {
calls.push('ensureReady');
},
canEditAdminLists: (actorNpub) => {
calls.push(['canEdit', actorNpub]);
return true;
},
addToBlacklist: async (actorNpub, targetNpub) => {
calls.push(['add', actorNpub, targetNpub]);
return { ok: true };
},
};
let refreshedWith = null;
const subscriptions = {
refreshActiveFeed: async (args) => {
refreshedWith = args;
},
};
const controller = new MoreMenuController({
accessControl,
subscriptions,
callbacks: {
getCurrentUserNpub: () => 'npub1actor',
getCurrentVideo: () => ({ pubkey: 'authorhex' }),
safeEncodeNpub: (pubkey) => `npub:${pubkey}`,
showError: (message) => {
throw new Error(`Unexpected error: ${message}`);
},
showSuccess: (message) => {
calls.push(['success', message]);
},
canCurrentUserManageBlacklist: () => true,
},
});
await controller.handleMoreMenuAction('blacklist-author', {
author: 'authorhex',
});
assert.deepEqual(calls, [
'ensureReady',
['canEdit', 'npub1actor'],
['add', 'npub1actor', 'npub:authorhex'],
['success', 'Creator added to the blacklist.'],
]);
assert.deepEqual(refreshedWith, { reason: 'admin-blacklist-update' });
});
test('blacklist-author shows error when no moderator session is available', async () => {
const errors = [];
const controller = new MoreMenuController({
callbacks: {
getCurrentUserNpub: () => null,
showError: (message) => errors.push(message),
getCurrentVideo: () => ({ pubkey: 'authorhex' }),
},
});
await controller.handleMoreMenuAction('blacklist-author', {
author: 'authorhex',
});
assert.deepEqual(errors, [
'Please login as a moderator to manage the blacklist.',
]);
});
test('block-author updates user blocks, reloads videos, and refreshes feeds', async () => {
const events = [];
const userBlocks = {
ensureLoaded: async (pubkey) => {
events.push(['ensureLoaded', pubkey]);
},
isBlocked: (pubkey) => {
events.push(['isBlocked', pubkey]);
return false;
},
addBlock: async (target, actor) => {
events.push(['addBlock', target, actor]);
},
};
const subscriptions = {
refreshActiveFeed: async (args) => {
events.push(['refreshActiveFeed', args]);
},
};
const controller = new MoreMenuController({
userBlocks,
subscriptions,
callbacks: {
getCurrentUserPubkey: () => 'actorhex',
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
safeDecodeNpub: (npub) => (npub === 'npub1target' ? 'targethex' : ''),
showError: (message) => {
throw new Error(`Unexpected error: ${message}`);
},
showSuccess: (message) => {
events.push(['success', message]);
},
loadVideos: async () => {
events.push(['loadVideos']);
},
onUserBlocksUpdated: () => {
events.push(['refreshBlockedList']);
},
},
});
await controller.handleMoreMenuAction('block-author', {
author: 'npub1target',
});
assert.deepEqual(events, [
['ensureLoaded', 'actorhex'],
['isBlocked', 'targethex'],
['addBlock', 'targethex', 'actorhex'],
['success', "Creator blocked. You won't see their videos anymore."],
['refreshBlockedList'],
['loadVideos'],
['refreshActiveFeed', { reason: 'user-block-update' }],
]);
});