mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #377 from PR0M3TH3AN/codex/refactor-more-menu-into-controller-yeuyxk
Refactor more menu logic into controller
This commit is contained in:
3 files changed
+832
-410
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' }],
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user