diff --git a/js/channelProfile.js b/js/channelProfile.js index 7e8fd071..50a70863 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -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; diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 82f7d6bd..e78d7ce6 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -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; } diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index f826b193..093be888 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -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", diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 7d82566e..0ed30b2a 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -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) { diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs index 0606962e..229d7357 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -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); +}); diff --git a/tests/more-menu-controller.test.mjs b/tests/more-menu-controller.test.mjs index 45564253..3aa68618 100644 --- a/tests/more-menu-controller.test.mjs +++ b/tests/more-menu-controller.test.mjs @@ -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' }], + ]); +});