Remove development comment from channel profile import

This commit is contained in:
thePR0M3TH3AN committed 2025-10-17 22:32:59 -04:00
1 parent 8492091780
commit fda1726769
6 files changed
+760 -10

No files matched your search

+42 -1
View File
@@ -5,7 +5,7 @@ import {
DEFAULT_RELAY_URLS,
convertEventToVideo as sharedConvertEventToVideo
} from "./nostr.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
@@ -16,6 +16,7 @@ import { VideoCard } from "./ui/components/VideoCard.js";
import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js";
import { ALLOW_NSFW_CONTENT } from "./config.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import moderationService from "./services/moderationService.js";
import {
calculateZapShares,
describeShareType,
@@ -1663,12 +1664,31 @@ async function updateChannelMenuState() {
userLogger.warn("Failed to refresh moderation lists for channel menu:", error);
}
try {
await moderationService.ensureViewerMuteListLoaded();
} catch (error) {
userLogger.warn("Failed to refresh viewer mute list for channel menu:", error);
}
const canBlacklist =
typeof app?.canCurrentUserManageBlacklist === "function"
? app.canCurrentUserManageBlacklist()
: false;
const buttons = menu.querySelectorAll("button[data-action]");
const toggleButtonVisibility = (button, shouldShow) => {
if (!button) {
return;
}
if (shouldShow) {
button.removeAttribute("hidden");
button.setAttribute("aria-hidden", "false");
} else {
button.setAttribute("hidden", "");
button.setAttribute("aria-hidden", "true");
}
};
buttons.forEach((button) => {
if (!button || button.nodeType !== 1) {
return;
@@ -1701,6 +1721,27 @@ async function updateChannelMenuState() {
return;
}
if (action === "mute-author" || action === "unmute-author") {
if (currentChannelHex) {
button.dataset.author = currentChannelHex;
} else {
delete button.dataset.author;
}
let isMuted = false;
if (currentChannelHex) {
try {
isMuted = moderationService.isAuthorMutedByViewer(currentChannelHex) === true;
} catch (error) {
userLogger.warn("Failed to resolve viewer mute state for channel menu:", error);
}
}
const shouldShow = action === "mute-author" ? !isMuted : isMuted;
toggleButtonVisibility(button, shouldShow && !!currentChannelHex);
return;
}
if (action === "blacklist-author") {
if (canBlacklist && currentChannelHex) {
button.dataset.author = currentChannelHex;
+236 -9
View File
@@ -319,6 +319,11 @@ export class ModerationService {
this.userBlocks = userBlockManager;
this.accessControl = accessControlService;
this.viewerMuteList = new Set();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
this.viewerMutePromise = null;
this.contactSubscription = null;
this.contactListPromise = null;
@@ -391,13 +396,34 @@ export class ModerationService {
this.trustedMuteLists.clear();
this.trustedMutedAuthors.clear();
this.viewerMuteList.clear();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
this.emit("trusted-mutes", { total: 0 });
}
isTrustedMuteOwner(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
if (normalized === this.viewerPubkey) {
return true;
}
return this.trustedContacts.has(normalized);
}
reconcileTrustedMuteSubscriptions(previousSet, nextSet) {
const previous = previousSet instanceof Set ? previousSet : new Set();
const next = nextSet instanceof Set ? nextSet : new Set();
const previous = previousSet instanceof Set ? new Set(previousSet) : new Set();
const next = nextSet instanceof Set ? new Set(nextSet) : new Set();
if (this.viewerPubkey) {
if (this.trustedMuteSubscriptions.has(this.viewerPubkey)) {
previous.add(this.viewerPubkey);
}
next.add(this.viewerPubkey);
}
for (const value of previous) {
if (!next.has(value)) {
@@ -436,7 +462,7 @@ export class ModerationService {
async subscribeToTrustedMuteList(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized || !this.trustedContacts.has(normalized)) {
if (!normalized || !this.isTrustedMuteOwner(normalized)) {
return;
}
@@ -466,7 +492,7 @@ export class ModerationService {
return;
}
if (!this.trustedContacts.has(normalized)) {
if (!this.isTrustedMuteOwner(normalized)) {
return;
}
@@ -501,9 +527,11 @@ export class ModerationService {
if (latest) {
this.ingestTrustedMuteEvent(latest);
}
} else {
this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" });
}
if (!this.trustedContacts.has(normalized)) {
if (!this.isTrustedMuteOwner(normalized)) {
return;
}
@@ -590,13 +618,22 @@ export class ModerationService {
}
}
const normalizedEventId = normalizeEventId(eventId);
const normalizedCreatedAt = ensureNumber(createdAt);
if (owner === this.viewerPubkey) {
this.viewerMuteList = new Set(sanitizedAuthors);
this.viewerMuteEventId = normalizedEventId || "";
this.viewerMuteUpdatedAt = normalizedCreatedAt;
}
if (!sanitizedAuthors.size) {
this.trustedMuteLists.delete(owner);
} else {
this.trustedMuteLists.set(owner, {
authors: sanitizedAuthors,
updatedAt: ensureNumber(createdAt),
eventId: normalizeEventId(eventId),
updatedAt: normalizedCreatedAt,
eventId: normalizedEventId,
});
for (const author of sanitizedAuthors) {
@@ -615,7 +652,7 @@ export class ModerationService {
applyTrustedMuteEvent(ownerPubkey, event) {
const owner = normalizeHex(ownerPubkey);
if (!owner || !this.trustedContacts.has(owner)) {
if (!owner || !this.isTrustedMuteOwner(owner)) {
return;
}
@@ -663,13 +700,202 @@ export class ModerationService {
}
const owner = normalizeHex(event.pubkey);
if (!owner || !this.trustedContacts.has(owner)) {
if (!owner || !this.isTrustedMuteOwner(owner)) {
return;
}
this.applyTrustedMuteEvent(owner, event);
}
getViewerMutedAuthors() {
return Array.from(this.viewerMuteList);
}
isAuthorMutedByViewer(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
return this.viewerMuteList.has(normalized);
}
async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
this.viewerMuteList.clear();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
return;
}
if (this.trustedMuteLists.has(normalized)) {
return;
}
if (this.viewerMutePromise) {
try {
await this.viewerMutePromise;
} catch (_) {
/* noop */
}
return;
}
this.viewerMutePromise = this.subscribeToTrustedMuteList(normalized)
.catch((error) => {
this.log("[moderationService] failed to load viewer mute list", error);
throw error;
})
.finally(() => {
this.viewerMutePromise = null;
});
try {
await this.viewerMutePromise;
} catch (_) {
/* noop */
}
}
async publishViewerMuteList({ owner, muted }) {
const viewer = normalizeHex(owner || this.viewerPubkey);
if (!viewer) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
await this.ensurePool();
const extension = typeof window !== "undefined" ? window.nostr : null;
if (!extension || typeof extension.signEvent !== "function") {
const error = new Error("nostr-extension-missing");
error.code = "nostr-extension-missing";
throw error;
}
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
"sign_event",
"get_public_key",
]);
if (!permissionResult?.ok) {
const error = new Error("extension-permission-denied");
error.code = "extension-permission-denied";
error.details = permissionResult?.error || null;
throw error;
}
}
const tags = [];
if (muted instanceof Set || Array.isArray(muted)) {
for (const value of muted) {
const normalized = normalizeHex(value);
if (!normalized || normalized === viewer) {
continue;
}
tags.push(["p", normalized]);
}
}
const event = {
kind: 10000,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
pubkey: viewer,
};
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
const wrapped = new Error("signature-failed");
wrapped.code = "signature-failed";
wrapped.details = error;
throw wrapped;
}
const relays = resolveRelayList(this.nostrClient, { write: true });
if (!relays.length) {
const error = new Error("no-relays-configured");
error.code = "no-relays-configured";
throw error;
}
let results = [];
try {
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
assertAnyRelayAccepted(results, { context: "mute list" });
} catch (error) {
const wrapped = new Error("mute-list-publish-failed");
wrapped.code = "mute-list-publish-failed";
wrapped.details = error;
throw wrapped;
}
this.applyTrustedMuteEvent(viewer, signedEvent);
return { ok: true, event: signedEvent, results };
}
async addAuthorToViewerMuteList(pubkey) {
const viewer = normalizeHex(this.viewerPubkey);
if (!viewer) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
const target = normalizeHex(pubkey);
if (!target) {
const error = new Error("invalid-target");
error.code = "invalid-target";
throw error;
}
if (target === viewer) {
const error = new Error("self");
error.code = "self";
throw error;
}
await this.ensureViewerMuteListLoaded(viewer);
if (this.viewerMuteList.has(target)) {
return { ok: true, already: true };
}
const next = new Set(this.viewerMuteList);
next.add(target);
return this.publishViewerMuteList({ owner: viewer, muted: next });
}
async removeAuthorFromViewerMuteList(pubkey) {
const viewer = normalizeHex(this.viewerPubkey);
if (!viewer) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
const target = normalizeHex(pubkey);
if (!target) {
return { ok: true, already: true };
}
await this.ensureViewerMuteListLoaded(viewer);
if (!this.viewerMuteList.has(target)) {
return { ok: true, already: true };
}
const next = new Set(this.viewerMuteList);
next.delete(target);
return this.publishViewerMuteList({ owner: viewer, muted: next });
}
isAuthorMutedByTrusted(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
@@ -910,6 +1136,7 @@ export class ModerationService {
});
await this.ensureUserBlocksLoaded(normalized);
await this.ensureViewerMuteListLoaded(normalized);
return this.viewerPubkey;
}
+24
View File
@@ -257,6 +257,18 @@ export function createVideoMoreMenuPanel({
});
}
appendMenuAction(doc, list, {
text: "Mute creator",
action: "mute-author",
dataset: { author: metadata.pubkey, context },
});
appendMenuAction(doc, list, {
text: "Unmute creator",
action: "unmute-author",
dataset: { author: metadata.pubkey, context },
});
if (canManageBlacklist) {
appendMenuAction(doc, list, {
text: "Blacklist creator",
@@ -307,6 +319,18 @@ export function createChannelProfileMenuPanel({
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Mute channel",
action: "mute-author",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Unmute channel",
action: "unmute-author",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Blacklist channel",
action: "blacklist-author",
+290
View File
@@ -79,6 +79,7 @@ export default class MoreMenuController {
this.boundVideoListBlacklistHandler = null;
this.popoversByTrigger = new Map();
this.activePopover = null;
this.activePopoverEntry = null;
this.reportModal = null;
this.reportModalPrepared = false;
@@ -582,6 +583,105 @@ export default class MoreMenuController {
);
}
setButtonVisibility(button, visible) {
if (!button) {
return;
}
if (visible) {
button.removeAttribute("hidden");
button.setAttribute("aria-hidden", "false");
} else {
button.setAttribute("hidden", "");
button.setAttribute("aria-hidden", "true");
}
}
syncMuteMenuButtons(panel, entry = {}) {
if (!panel || typeof panel.querySelector !== "function") {
return;
}
const muteButton = panel.querySelector('button[data-action="mute-author"]');
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
if (!muteButton && !unmuteButton) {
return;
}
const contextVideo = entry?.context?.video || null;
let authorCandidate = contextVideo?.pubkey || "";
if (!authorCandidate) {
if (muteButton?.dataset?.author) {
authorCandidate = muteButton.dataset.author;
} else if (unmuteButton?.dataset?.author) {
authorCandidate = unmuteButton.dataset.author;
}
}
const trimmed = typeof authorCandidate === "string" ? authorCandidate.trim() : "";
let normalized = "";
if (trimmed) {
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
normalized = trimmed.toLowerCase();
} else if (trimmed.startsWith("npub1")) {
normalized = this.callbacks.safeDecodeNpub(trimmed) || "";
}
}
if (normalized) {
if (muteButton) {
muteButton.dataset.author = normalized;
}
if (unmuteButton) {
unmuteButton.dataset.author = normalized;
}
}
let isMuted = false;
if (normalized) {
try {
isMuted = this.moderationService.isAuthorMutedByViewer(normalized) === true;
} catch (error) {
if (this.isDevMode) {
userLogger.warn("[MoreMenu] Failed to resolve viewer mute state", error);
}
}
}
const hasTarget = Boolean(normalized);
this.setButtonVisibility(muteButton, hasTarget && !isMuted);
this.setButtonVisibility(unmuteButton, hasTarget && isMuted);
}
refreshActiveMuteButtons({ author = "" } = {}) {
if (!this.activePopoverEntry || !this.activePopover) {
return;
}
const panel =
typeof this.activePopover.getPanel === "function"
? this.activePopover.getPanel()
: null;
if (!panel) {
return;
}
if (author) {
const trimmed = author.trim();
if (trimmed) {
const muteButton = panel.querySelector('button[data-action="mute-author"]');
const unmuteButton = panel.querySelector('button[data-action="unmute-author"]');
if (muteButton) {
muteButton.dataset.author = trimmed;
}
if (unmuteButton) {
unmuteButton.dataset.author = trimmed;
}
}
}
this.syncMuteMenuButtons(panel, this.activePopoverEntry);
}
createPopoverRender(entry) {
return ({ document: documentRef, close }) => {
const panel = createVideoMoreMenuPanel({
@@ -599,6 +699,8 @@ export default class MoreMenuController {
return null;
}
this.syncMuteMenuButtons(panel, entry);
const buttons = panel.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
const HTMLElementCtor = this.getHTMLElementConstructor(button?.ownerDocument);
@@ -656,6 +758,12 @@ export default class MoreMenuController {
const result = await originalOpen(...args);
if (result) {
this.activePopover = popover;
this.activePopoverEntry = entry;
const panel =
typeof popover.getPanel === "function" ? popover.getPanel() : null;
if (panel) {
this.syncMuteMenuButtons(panel, entry);
}
}
return result;
};
@@ -670,6 +778,7 @@ export default class MoreMenuController {
const result = originalClose({ restoreFocus });
if (result && this.activePopover === popover) {
this.activePopover = null;
this.activePopoverEntry = null;
}
if (result && typeof entry?.context?.onClose === "function") {
try {
@@ -692,6 +801,7 @@ export default class MoreMenuController {
popover.destroy = (...args) => {
if (this.activePopover === popover) {
this.activePopover = null;
this.activePopoverEntry = null;
}
originalDestroy(...args);
if (trigger && this.popoversByTrigger.get(trigger) === entry) {
@@ -942,6 +1052,36 @@ export default class MoreMenuController {
const context = dataset.context || "";
const currentVideo = this.callbacks.getCurrentVideo();
const resolveTargetAuthorHex = () => {
const candidates = [
dataset.author,
dataset.npub,
context === "modal" ? currentVideo?.pubkey : "",
currentVideo?.pubkey,
];
for (const candidate of candidates) {
if (typeof candidate !== "string") {
continue;
}
const trimmed = candidate.trim();
if (!trimmed) {
continue;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.callbacks.safeDecodeNpub(trimmed);
if (decoded) {
return decoded;
}
}
}
return "";
};
switch (normalized) {
case "open-channel": {
if (context === "modal") {
@@ -1028,6 +1168,156 @@ export default class MoreMenuController {
}
break;
}
case "mute-author": {
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
if (!viewerPubkey) {
this.callbacks.showError("Please login to manage your mute list.");
break;
}
const targetHex = resolveTargetAuthorHex();
if (!targetHex) {
this.callbacks.showError("Unable to determine the creator to mute.");
break;
}
if (targetHex === viewerPubkey) {
this.callbacks.showError("You cannot mute yourself.");
break;
}
try {
const result = await this.moderationService.addAuthorToViewerMuteList(
targetHex,
);
if (result?.already) {
this.callbacks.showSuccess("You already muted this creator.");
} else {
this.callbacks.showSuccess(
"Creator muted. Their videos will be downranked in your feeds.",
);
}
try {
await this.callbacks.loadVideos();
} catch (loadError) {
if (this.isDevMode) {
userLogger.warn("[MoreMenu] Failed to reload videos after muting", loadError);
}
}
try {
await this.subscriptions?.refreshActiveFeed?.({
reason: "viewer-mute-update",
});
} catch (refreshError) {
if (this.isDevMode) {
userLogger.warn(
"[Subscriptions] Failed to refresh after viewer mute update:",
refreshError,
);
}
}
this.refreshActiveMuteButtons({ author: targetHex });
} catch (error) {
switch (error?.code) {
case "viewer-not-logged-in":
this.callbacks.showError("Please login to manage your mute list.");
break;
case "invalid-target":
this.callbacks.showError("Unable to determine the creator to mute.");
break;
case "self":
this.callbacks.showError("You cannot mute yourself.");
break;
case "nostr-extension-missing":
this.callbacks.showError(
"A Nostr extension is required to update your mute list.",
);
break;
case "extension-permission-denied":
this.callbacks.showError(
"Allow your Nostr extension to sign events before muting creators.",
);
break;
default:
this.callbacks.showError(
"Failed to update your mute list. Please try again.",
);
break;
}
}
break;
}
case "unmute-author": {
const viewerPubkey = this.callbacks.getCurrentUserPubkey();
if (!viewerPubkey) {
this.callbacks.showError("Please login to manage your mute list.");
break;
}
const targetHex = resolveTargetAuthorHex();
if (!targetHex) {
this.callbacks.showError("Unable to determine the creator to unmute.");
break;
}
try {
const result = await this.moderationService.removeAuthorFromViewerMuteList(
targetHex,
);
if (result?.already) {
this.callbacks.showSuccess("This creator is not on your mute list.");
} else {
this.callbacks.showSuccess(
"Creator removed from your mute list.",
);
}
try {
await this.callbacks.loadVideos();
} catch (loadError) {
if (this.isDevMode) {
userLogger.warn(
"[MoreMenu] Failed to reload videos after unmuting",
loadError,
);
}
}
try {
await this.subscriptions?.refreshActiveFeed?.({
reason: "viewer-mute-update",
});
} catch (refreshError) {
if (this.isDevMode) {
userLogger.warn(
"[Subscriptions] Failed to refresh after viewer mute removal:",
refreshError,
);
}
}
this.refreshActiveMuteButtons({ author: targetHex });
} catch (error) {
switch (error?.code) {
case "viewer-not-logged-in":
this.callbacks.showError("Please login to manage your mute list.");
break;
case "nostr-extension-missing":
this.callbacks.showError(
"A Nostr extension is required to update your mute list.",
);
break;
case "extension-permission-denied":
this.callbacks.showError(
"Allow your Nostr extension to sign events before updating your mute list.",
);
break;
default:
this.callbacks.showError(
"Failed to update your mute list. Please try again.",
);
break;
}
}
break;
}
case "blacklist-author": {
const actorNpub = this.callbacks.getCurrentUserNpub();
if (!actorNpub) {
+68
View File
@@ -157,3 +157,71 @@ test("trusted mute aggregation tracks F1 mute lists", () => {
assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), false);
assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), []);
});
test("viewer mute list publishes and updates aggregation", async (t) => {
const publishCalls = [];
const nostrClient = {
pool: {
list: async () => [],
sub: () => ({
on: () => {},
unsub: () => {},
}),
publish: (urls, event) => {
publishCalls.push({ urls, event });
return {
on: (eventName, handler) => {
if (eventName === "ok") {
handler();
}
},
};
},
},
relays: ["wss://relay.example"],
ensurePool: async () => {},
ensureExtensionPermissions: async () => ({ ok: true }),
};
const service = new ModerationService({
logger: () => {},
nostrClient,
});
const previousNostr = globalThis.window.nostr;
globalThis.window.nostr = {
async signEvent(event) {
return { ...event, id: "signed-event-id" };
},
};
t.after(() => {
globalThis.window.nostr = previousNostr;
});
const viewerHex = "f".repeat(64);
await service.setViewerPubkey(viewerHex);
const targetHex = "e".repeat(64);
await service.addAuthorToViewerMuteList(targetHex);
assert.equal(service.isAuthorMutedByViewer(targetHex), true);
assert.equal(service.isAuthorMutedByTrusted(targetHex), true);
assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), [viewerHex]);
assert.equal(publishCalls.length, 1);
const firstEvent = publishCalls[0].event;
assert.equal(firstEvent.tags.length, 1);
assert.deepEqual(firstEvent.tags[0], ["p", targetHex]);
await service.removeAuthorFromViewerMuteList(targetHex);
assert.equal(service.isAuthorMutedByViewer(targetHex), false);
assert.equal(service.isAuthorMutedByTrusted(targetHex), false);
assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), []);
assert.equal(publishCalls.length, 2);
const secondEvent = publishCalls[1].event;
assert.equal(secondEvent.tags.length, 0);
});
+100
View File
@@ -159,3 +159,103 @@ test('block-author updates user blocks, reloads videos, and refreshes feeds', as
['refreshActiveFeed', { reason: 'user-block-update' }],
]);
});
test('mute-author updates viewer mute list and refreshes feeds', async () => {
const events = [];
const moderationStub = {
addAuthorToViewerMuteList: async (target) => {
events.push(['addMute', target]);
return { ok: true };
},
removeAuthorFromViewerMuteList: async () => {
throw new Error('unreachable');
},
isAuthorMutedByViewer: () => false,
};
const subscriptions = {
refreshActiveFeed: async (args) => {
events.push(['refreshActiveFeed', args]);
},
};
const controller = new MoreMenuController({
moderationService: moderationStub,
subscriptions,
callbacks: {
getCurrentUserPubkey: () => 'viewerhex',
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
loadVideos: async () => {
events.push(['loadVideos']);
},
showSuccess: (message) => {
events.push(['success', message]);
},
showError: (message) => {
events.push(['error', message]);
},
safeDecodeNpub: () => '',
},
});
const targetHex = 'd'.repeat(64);
await controller.handleMoreMenuAction('mute-author', { author: targetHex });
assert.deepEqual(events, [
['addMute', targetHex],
['success', 'Creator muted. Their videos will be downranked in your feeds.'],
['loadVideos'],
['refreshActiveFeed', { reason: 'viewer-mute-update' }],
]);
});
test('unmute-author removes creators from viewer mute list', async () => {
const events = [];
const moderationStub = {
addAuthorToViewerMuteList: async () => {
throw new Error('unreachable');
},
removeAuthorFromViewerMuteList: async (target) => {
events.push(['removeMute', target]);
return { ok: true };
},
isAuthorMutedByViewer: () => true,
};
const subscriptions = {
refreshActiveFeed: async (args) => {
events.push(['refreshActiveFeed', args]);
},
};
const controller = new MoreMenuController({
moderationService: moderationStub,
subscriptions,
callbacks: {
getCurrentUserPubkey: () => 'viewerhex',
getCurrentVideo: () => ({ pubkey: 'fallbackhex' }),
loadVideos: async () => {
events.push(['loadVideos']);
},
showSuccess: (message) => {
events.push(['success', message]);
},
showError: (message) => {
events.push(['error', message]);
},
safeDecodeNpub: () => '',
},
});
const targetHex = 'e'.repeat(64);
await controller.handleMoreMenuAction('unmute-author', { author: targetHex });
assert.deepEqual(events, [
['removeMute', targetHex],
['success', 'Creator removed from your mute list.'],
['loadVideos'],
['refreshActiveFeed', { reason: 'viewer-mute-update' }],
]);
});