diff --git a/js/app.js b/js/app.js index adcf6993..0f3b3c7c 100644 --- a/js/app.js +++ b/js/app.js @@ -92,11 +92,8 @@ import { openStaticModal, closeStaticModal, } from "./ui/components/staticModalAccessibility.js"; -import createTagPreferenceMenu, { - TAG_PREFERENCE_ACTIONS, - applyTagPreferenceMenuState, -} from "./ui/components/tagPreferenceMenu.js"; import LoginModalController from "./ui/loginModalController.js"; +import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js"; import { pointerArrayToKey } from "./utils/pointer.js"; import resolveVideoPointer, { buildVideoAddressPointer, @@ -250,6 +247,34 @@ class Application { } ); + this.tagPreferenceMenuController = new TagPreferenceMenuController({ + services: { hashtagPreferences }, + callbacks: { + isLoggedIn: () => this.isUserLoggedIn(), + getMembership: (tag) => this.getTagPreferenceMembership(tag), + showError: (msg) => this.showError(msg), + describeError: (err, opts) => + this.describeHashtagPreferencesError(err, opts), + onPreferenceUpdate: () => { + this.updateCachedHashtagPreferences(); + this.refreshTagPreferenceUi(); + }, + onMenuOpen: (trigger) => { + this.closeVideoSettingsMenu({ restoreFocus: false }); + this.closeAllMoreMenus({ + restoreFocus: false, + skipTrigger: trigger, + skipView: true, + }); + }, + getPubkey: () => this.normalizeHexPubkey(this.pubkey), + }, + helpers: { + createPopover, + getDesignSystem: () => this.designSystemContext, + }, + }); + this.initializeModerationActionController(); } @@ -2720,38 +2745,7 @@ class Application { } } - this.refreshActiveTagPreferenceMenus(); - } - - refreshActiveTagPreferenceMenus() { - if (!this.tagPreferencePopovers) { - return; - } - - const isLoggedIn = this.isUserLoggedIn(); - this.tagPreferencePopovers.forEach((entry) => { - if (!entry) { - return; - } - - const buttons = entry.buttons || {}; - if (!buttons || Object.keys(buttons).length === 0) { - return; - } - - try { - applyTagPreferenceMenuState({ - buttons, - membership: this.getTagPreferenceMembership(entry.tag), - isLoggedIn, - }); - } catch (error) { - devLogger.warn( - "[Application] Failed to refresh tag preference menu state:", - error, - ); - } - }); + this.tagPreferenceMenuController.refreshActiveMenus(); } async loadHashtagPreferencesForPubkey(pubkey) { @@ -7166,7 +7160,9 @@ class Application { } this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false }); - this.closeTagPreferenceMenus({ restoreFocus: options?.restoreFocus !== false }); + this.tagPreferenceMenuController.closeMenus({ + restoreFocus: options?.restoreFocus !== false, + }); } attachMoreMenuHandlers(container) { @@ -7360,298 +7356,20 @@ class Application { return closed; } - ensureTagPreferencePopover(detail = {}) { - const triggerCandidate = detail?.trigger || null; - const trigger = - triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; - const rawTag = typeof detail?.tag === "string" ? detail.tag : ""; - const tag = rawTag.trim(); - - if (!trigger || !tag) { - return null; - } - - let entry = this.tagPreferencePopovers.get(trigger); - - const render = ({ document: documentRef, close }) => { - const menu = createTagPreferenceMenu({ - document: documentRef, - tag: entry.tag, - isLoggedIn: this.isUserLoggedIn(), - membership: this.getTagPreferenceMembership(entry.tag), - designSystem: this.designSystemContext, - onAction: (action, actionDetail = {}) => { - void this.handleTagPreferenceMenuAction(action, { - tag: entry.tag, - trigger, - video: entry.video || null, - closePopover: close, - actionDetail, - }); - }, - }); - - if (!menu?.panel) { - return null; - } - - entry.panel = menu.panel; - entry.buttons = menu.buttons; - return menu.panel; - }; - - if (!entry) { - entry = { - trigger, - tag, - context: detail.context || "", - video: detail.video || null, - panel: null, - buttons: null, - popover: null, - }; - - const ownerDocument = - trigger.ownerDocument || (typeof document !== "undefined" ? document : null); - - const popover = createPopover(trigger, render, { - document: ownerDocument, - placement: "bottom-start", - restoreFocusOnClose: true, - }); - - if (!popover) { - return null; - } - - const originalDestroy = popover.destroy?.bind(popover); - if (typeof originalDestroy === "function") { - popover.destroy = (...args) => { - originalDestroy(...args); - if (this.tagPreferencePopovers.get(trigger) === entry) { - this.tagPreferencePopovers.delete(trigger); - } - }; - } - - entry.popover = popover; - this.tagPreferencePopovers.set(trigger, entry); - } else { - entry.tag = tag; - entry.context = detail.context || entry.context || ""; - entry.video = detail.video || entry.video || null; - } - - return entry; + ensureTagPreferencePopover(detail) { + return this.tagPreferenceMenuController.ensurePopover(detail); } - requestTagPreferenceMenu(detail = {}) { - const entry = this.ensureTagPreferencePopover(detail); - if (!entry?.popover) { - return; - } - - const popover = entry.popover; - const restoreFocus = detail.restoreFocus !== false; - - if (typeof popover.isOpen === "function" && popover.isOpen()) { - popover.close({ restoreFocus }); - return; - } - - this.closeTagPreferenceMenus({ - restoreFocus: false, - skipTrigger: entry.trigger, - }); - this.closeVideoSettingsMenu({ restoreFocus: false }); - this.closeAllMoreMenus({ - restoreFocus: false, - skipTrigger: entry.trigger, - skipView: true, - }); - - popover - .open() - .then(() => { - this.refreshActiveTagPreferenceMenus(); - }) - .catch((error) => - userLogger.error("[TagPreferenceMenu] Failed to open popover:", error), - ); + requestTagPreferenceMenu(detail) { + return this.tagPreferenceMenuController.requestMenu(detail); } - closeTagPreferenceMenus(detail = {}) { - const triggerCandidate = detail?.trigger || null; - const trigger = - triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; - const restoreFocus = detail?.restoreFocus !== false; - const skipTrigger = detail?.skipTrigger || null; - - if (trigger) { - const entry = this.tagPreferencePopovers.get(trigger); - if (entry?.popover && typeof entry.popover.close === "function") { - return entry.popover.close({ restoreFocus }); - } - return false; - } - - let closed = false; - this.tagPreferencePopovers.forEach((entry, key) => { - if (!entry?.popover || typeof entry.popover.close !== "function") { - return; - } - if (skipTrigger && key === skipTrigger) { - return; - } - const result = entry.popover.close({ restoreFocus }); - closed = closed || result; - }); - return closed; - } - - async persistHashtagPreferencesFromMenu() { - const service = this.hashtagPreferences; - const publish = - service && typeof service.publish === "function" ? service.publish : null; - - if (!publish) { - const message = this.describeHashtagPreferencesError(null, { - fallbackMessage: "Hashtag preferences are unavailable right now.", - }); - if (message) { - this.showError(message); - } - const error = new Error( - message || "Hashtag preferences are unavailable right now.", - ); - error.code = "service-unavailable"; - throw error; - } - - if (this.hashtagPreferencesPublishInFlight) { - return this.hashtagPreferencesPublishPromise; - } - - const normalizedPubkey = this.normalizeHexPubkey(this.pubkey); - const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; - - this.hashtagPreferencesPublishInFlight = true; - - const publishPromise = (async () => { - try { - return await publish.call(service, payload); - } catch (error) { - const failure = - error instanceof Error ? error : new Error(String(error || "")); - if (!failure.code) { - failure.code = "hashtag-preferences-publish-failed"; - } - const message = this.describeHashtagPreferencesError(failure, { - operation: "update", - }); - if (message) { - this.showError(message); - } - throw failure; - } finally { - this.hashtagPreferencesPublishInFlight = false; - this.hashtagPreferencesPublishPromise = null; - } - })(); - - this.hashtagPreferencesPublishPromise = publishPromise; - return publishPromise; - } - - async handleTagPreferenceMenuAction(action, detail = {}) { - const tag = typeof detail?.tag === "string" ? detail.tag : ""; - if (!tag) { - return; - } - - const service = this.hashtagPreferences; - if (!service) { - return; - } - - let result = false; - try { - switch (action) { - case TAG_PREFERENCE_ACTIONS.ADD_INTEREST: - result = service.addInterest(tag); - break; - case TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST: - result = service.removeInterest(tag); - break; - case TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST: - result = service.addDisinterest(tag); - break; - case TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST: - result = service.removeDisinterest(tag); - break; - default: - userLogger.warn(`[TagPreferenceMenu] Unhandled action: ${action}`); - return; - } - } catch (error) { - devLogger.error( - "[Application] Failed to mutate hashtag preference via menu:", - error, - ); - const message = this.describeHashtagPreferencesError(error, { - operation: "update", - }); - if (message) { - this.showError(message); - } - return; - } - - if (!result) { - return; - } - - this.updateCachedHashtagPreferences(); - this.refreshTagPreferenceUi(); - - try { - await this.persistHashtagPreferencesFromMenu(); - } catch (error) { - return; - } - - this.updateCachedHashtagPreferences(); - this.refreshTagPreferenceUi(); - - if (typeof detail?.closePopover === "function") { - detail.closePopover({ restoreFocus: false }); - } + closeTagPreferenceMenus(detail) { + return this.tagPreferenceMenuController.closeMenus(detail); } handleTagPreferenceActivation(detail = {}) { - const tag = typeof detail?.tag === "string" ? detail.tag : ""; - if (!tag) { - return; - } - - const triggerCandidate = detail?.trigger || null; - const trigger = - triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; - if (!trigger) { - return; - } - - if (detail?.event) { - detail.event.preventDefault?.(); - detail.event.stopPropagation?.(); - } - - this.requestTagPreferenceMenu({ - trigger, - tag, - context: detail?.context || "", - video: detail?.video || null, - }); + this.tagPreferenceMenuController.handleActivation(detail); } syncModalMoreMenuData() { @@ -10664,9 +10382,8 @@ class Application { this.modalManager = null; } - this.closeTagPreferenceMenus({ restoreFocus: false }); - if (this.tagPreferencePopovers) { - this.tagPreferencePopovers.clear(); + if (this.tagPreferenceMenuController) { + this.tagPreferenceMenuController.clear(); } if (this.videoListView) { diff --git a/js/ui/tagPreferenceMenuController.js b/js/ui/tagPreferenceMenuController.js new file mode 100644 index 00000000..6036fcbf --- /dev/null +++ b/js/ui/tagPreferenceMenuController.js @@ -0,0 +1,357 @@ +import { TAG_PREFERENCE_ACTIONS, applyTagPreferenceMenuState, createTagPreferenceMenu } from "./components/tagPreferenceMenu.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +export default class TagPreferenceMenuController { + constructor({ services = {}, callbacks = {}, helpers = {} } = {}) { + this.services = services; + this.callbacks = callbacks; + this.helpers = helpers; + + this.popovers = new Map(); + this.publishInFlight = false; + this.publishPromise = null; + } + + ensurePopover(detail = {}) { + const triggerCandidate = detail?.trigger || null; + const trigger = + triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; + const rawTag = typeof detail?.tag === "string" ? detail.tag : ""; + const tag = rawTag.trim(); + + if (!trigger || !tag) { + return null; + } + + let entry = this.popovers.get(trigger); + + const render = ({ document: documentRef, close }) => { + const menu = createTagPreferenceMenu({ + document: documentRef, + tag: entry.tag, + isLoggedIn: this.callbacks.isLoggedIn(), + membership: this.callbacks.getMembership(entry.tag), + designSystem: this.helpers.getDesignSystem + ? this.helpers.getDesignSystem() + : null, + onAction: (action, actionDetail = {}) => { + void this.handleMenuAction(action, { + tag: entry.tag, + trigger, + video: entry.video || null, + closePopover: close, + actionDetail, + }); + }, + }); + + if (!menu?.panel) { + return null; + } + + entry.panel = menu.panel; + entry.buttons = menu.buttons; + return menu.panel; + }; + + if (!entry) { + entry = { + trigger, + tag, + context: detail.context || "", + video: detail.video || null, + panel: null, + buttons: null, + popover: null, + }; + + const ownerDocument = + trigger.ownerDocument || (typeof document !== "undefined" ? document : null); + + const popover = this.helpers.createPopover(trigger, render, { + document: ownerDocument, + placement: "bottom-start", + restoreFocusOnClose: true, + }); + + if (!popover) { + return null; + } + + const originalDestroy = popover.destroy?.bind(popover); + if (typeof originalDestroy === "function") { + popover.destroy = (...args) => { + originalDestroy(...args); + if (this.popovers.get(trigger) === entry) { + this.popovers.delete(trigger); + } + }; + } + + entry.popover = popover; + this.popovers.set(trigger, entry); + } else { + entry.tag = tag; + entry.context = detail.context || entry.context || ""; + entry.video = detail.video || entry.video || null; + } + + return entry; + } + + requestMenu(detail = {}) { + const entry = this.ensurePopover(detail); + if (!entry?.popover) { + return; + } + + const popover = entry.popover; + const restoreFocus = detail.restoreFocus !== false; + + if (typeof popover.isOpen === "function" && popover.isOpen()) { + popover.close({ restoreFocus }); + return; + } + + this.closeMenus({ + restoreFocus: false, + skipTrigger: entry.trigger, + }); + + // Notify app to close other menus + if (this.callbacks.onMenuOpen) { + this.callbacks.onMenuOpen(entry.trigger); + } + + popover + .open() + .then(() => { + this.refreshActiveMenus(); + }) + .catch((error) => + userLogger.error("[TagPreferenceMenu] Failed to open popover:", error), + ); + } + + closeMenus(detail = {}) { + const triggerCandidate = detail?.trigger || null; + const trigger = + triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; + const restoreFocus = detail?.restoreFocus !== false; + const skipTrigger = detail?.skipTrigger || null; + + if (trigger) { + const entry = this.popovers.get(trigger); + if (entry?.popover && typeof entry.popover.close === "function") { + return entry.popover.close({ restoreFocus }); + } + return false; + } + + let closed = false; + this.popovers.forEach((entry, key) => { + if (!entry?.popover || typeof entry.popover.close !== "function") { + return; + } + if (skipTrigger && key === skipTrigger) { + return; + } + const result = entry.popover.close({ restoreFocus }); + closed = closed || result; + }); + return closed; + } + + persistPreferencesFromMenu() { + const service = this.services.hashtagPreferences; + const publish = + service && typeof service.publish === "function" ? service.publish : null; + + const describe = this.callbacks.describeError; + + if (!publish) { + const message = + typeof describe === "function" + ? describe(null, { + fallbackMessage: "Hashtag preferences are unavailable right now.", + }) + : "Hashtag preferences are unavailable right now."; + + if (this.callbacks.showError) { + this.callbacks.showError(message); + } + const error = new Error(message); + error.code = "service-unavailable"; + return Promise.reject(error); + } + + if (this.publishInFlight) { + return this.publishPromise; + } + + const pubkey = this.callbacks.getPubkey ? this.callbacks.getPubkey() : null; + const payload = pubkey ? { pubkey } : {}; + + this.publishInFlight = true; + + const publishPromise = (async () => { + try { + return await publish.call(service, payload); + } catch (error) { + const failure = + error instanceof Error ? error : new Error(String(error || "")); + if (!failure.code) { + failure.code = "hashtag-preferences-publish-failed"; + } + + if (this.callbacks.showError) { + const message = + typeof describe === "function" + ? describe(failure, { operation: "update" }) + : failure.message || "Failed to update preferences."; + this.callbacks.showError(message); + } + throw failure; + } finally { + this.publishInFlight = false; + this.publishPromise = null; + } + })(); + + this.publishPromise = publishPromise; + return publishPromise; + } + + async handleMenuAction(action, detail = {}) { + const tag = typeof detail?.tag === "string" ? detail.tag : ""; + if (!tag) { + return; + } + + const service = this.services.hashtagPreferences; + if (!service) { + return; + } + + let result = false; + try { + switch (action) { + case TAG_PREFERENCE_ACTIONS.ADD_INTEREST: + result = service.addInterest(tag); + break; + case TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST: + result = service.removeInterest(tag); + break; + case TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST: + result = service.addDisinterest(tag); + break; + case TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST: + result = service.removeDisinterest(tag); + break; + default: + userLogger.warn(`[TagPreferenceMenu] Unhandled action: ${action}`); + return; + } + } catch (error) { + devLogger.error( + "[TagPreferenceMenuController] Failed to mutate hashtag preference via menu:", + error, + ); + if (this.callbacks.showError) { + const describe = this.callbacks.describeError; + const message = + typeof describe === "function" + ? describe(error, { operation: "update" }) + : "Failed to update hashtag preferences."; + this.callbacks.showError(message); + } + return; + } + + if (!result) { + return; + } + + if (this.callbacks.onPreferenceUpdate) { + this.callbacks.onPreferenceUpdate(); + } + + try { + await this.persistPreferencesFromMenu(); + } catch (error) { + return; + } + + if (this.callbacks.onPreferenceUpdate) { + this.callbacks.onPreferenceUpdate(); + } + + if (typeof detail?.closePopover === "function") { + detail.closePopover({ restoreFocus: false }); + } + } + + handleActivation(detail = {}) { + const tag = typeof detail?.tag === "string" ? detail.tag : ""; + if (!tag) { + return; + } + + const triggerCandidate = detail?.trigger || null; + const trigger = + triggerCandidate && triggerCandidate.nodeType === 1 ? triggerCandidate : null; + if (!trigger) { + return; + } + + if (detail?.event) { + detail.event.preventDefault?.(); + detail.event.stopPropagation?.(); + } + + this.requestMenu({ + trigger, + tag, + context: detail?.context || "", + video: detail?.video || null, + }); + } + + refreshActiveMenus() { + if (!this.popovers) { + return; + } + + const isLoggedIn = this.callbacks.isLoggedIn(); + this.popovers.forEach((entry) => { + if (!entry) { + return; + } + + const buttons = entry.buttons || {}; + if (!buttons || Object.keys(buttons).length === 0) { + return; + } + + try { + applyTagPreferenceMenuState({ + buttons, + membership: this.callbacks.getMembership(entry.tag), + isLoggedIn, + }); + } catch (error) { + devLogger.warn( + "[TagPreferenceMenuController] Failed to refresh tag preference menu state:", + error, + ); + } + }); + } + + clear() { + this.closeMenus({ restoreFocus: false }); + if (this.popovers) { + this.popovers.clear(); + } + } +} diff --git a/tests/ui/tagPreferenceMenuController.test.mjs b/tests/ui/tagPreferenceMenuController.test.mjs new file mode 100644 index 00000000..c6524cf5 --- /dev/null +++ b/tests/ui/tagPreferenceMenuController.test.mjs @@ -0,0 +1,185 @@ + +import assert from "assert"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import TagPreferenceMenuController from "../../js/ui/tagPreferenceMenuController.js"; +import { TAG_PREFERENCE_ACTIONS } from "../../js/ui/components/tagPreferenceMenu.js"; + +describe("TagPreferenceMenuController", () => { + let controller; + let mockHashtagPreferences; + let mockServices; + let mockCallbacks; + let mockHelpers; + let mockPopover; + let mockPopoverInstance; + + beforeEach(() => { + mockHashtagPreferences = { + addInterest: mock.fn(() => true), + removeInterest: mock.fn(() => true), + addDisinterest: mock.fn(() => true), + removeDisinterest: mock.fn(() => true), + publish: mock.fn(async () => true), + }; + + mockServices = { + hashtagPreferences: mockHashtagPreferences, + }; + + mockCallbacks = { + isLoggedIn: mock.fn(() => true), + getMembership: mock.fn(() => ({ interest: false, disinterest: false })), + showError: mock.fn(), + onPreferenceUpdate: mock.fn(), + onMenuOpen: mock.fn(), + getPubkey: mock.fn(() => "test-pubkey"), + }; + + mockPopoverInstance = { + open: mock.fn(async () => {}), + close: mock.fn(() => true), + destroy: mock.fn(), + isOpen: mock.fn(() => false), + }; + + mockPopover = mock.fn(() => mockPopoverInstance); + + mockHelpers = { + createPopover: mockPopover, + designSystem: { mode: "default" }, + }; + + controller = new TagPreferenceMenuController({ + services: mockServices, + callbacks: mockCallbacks, + helpers: mockHelpers, + }); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("ensurePopover", () => { + it("should create a new popover entry if one does not exist", () => { + const trigger = { nodeType: 1, ownerDocument: global.document }; // Mock DOM element + const detail = { trigger, tag: "bitcoin" }; + + const entry = controller.ensurePopover(detail); + + assert.ok(entry); + assert.strictEqual(entry.tag, "bitcoin"); + assert.strictEqual(entry.trigger, trigger); + assert.strictEqual(entry.popover, mockPopoverInstance); + assert.strictEqual(mockPopover.mock.calls.length, 1); + }); + + it("should return existing entry if one exists", () => { + const trigger = { nodeType: 1, ownerDocument: global.document }; + const detail = { trigger, tag: "bitcoin" }; + + const entry1 = controller.ensurePopover(detail); + const entry2 = controller.ensurePopover(detail); + + assert.strictEqual(entry1, entry2); + assert.strictEqual(mockPopover.mock.calls.length, 1); + }); + + it("should return null if trigger or tag is missing", () => { + assert.strictEqual(controller.ensurePopover({}), null); + assert.strictEqual(controller.ensurePopover({ trigger: {} }), null); // trigger not element + assert.strictEqual(controller.ensurePopover({ trigger: { nodeType: 1 } }), null); // missing tag + }); + }); + + describe("requestMenu", () => { + it("should ensure popover, close others, and open the requested one", async () => { + const trigger = { nodeType: 1, ownerDocument: global.document }; + const detail = { trigger, tag: "bitcoin" }; + + // Spy on closeMenus + const closeMenusSpy = mock.method(controller, "closeMenus"); + + controller.requestMenu(detail); + + assert.strictEqual(closeMenusSpy.mock.calls.length, 1); + assert.strictEqual(mockCallbacks.onMenuOpen.mock.calls.length, 1); + assert.strictEqual(mockPopoverInstance.open.mock.calls.length, 1); + }); + + it("should close the popover if it is already open", () => { + const trigger = { nodeType: 1, ownerDocument: global.document }; + const detail = { trigger, tag: "bitcoin" }; + + mockPopoverInstance.isOpen = mock.fn(() => true); + + controller.requestMenu(detail); + + assert.strictEqual(mockPopoverInstance.close.mock.calls.length, 1); + assert.strictEqual(mockPopoverInstance.open.mock.calls.length, 0); + }); + }); + + describe("handleMenuAction", () => { + it("should call service method, notify update, publish, and notify update again", async () => { + const tag = "bitcoin"; + const action = TAG_PREFERENCE_ACTIONS.ADD_INTEREST; + + await controller.handleMenuAction(action, { tag }); + + assert.strictEqual(mockHashtagPreferences.addInterest.mock.calls.length, 1); + assert.strictEqual(mockHashtagPreferences.addInterest.mock.calls[0].arguments[0], tag); + + // onPreferenceUpdate should be called twice (optimistic + confirmed) + assert.strictEqual(mockCallbacks.onPreferenceUpdate.mock.calls.length, 2); + + // publish should be called + assert.strictEqual(mockHashtagPreferences.publish.mock.calls.length, 1); + }); + + it("should handle error and call showError", async () => { + const tag = "bitcoin"; + const action = TAG_PREFERENCE_ACTIONS.ADD_INTEREST; + + mockHashtagPreferences.addInterest = mock.fn(() => { + throw new Error("Service error"); + }); + + await controller.handleMenuAction(action, { tag }); + + assert.strictEqual(mockCallbacks.showError.mock.calls.length, 1); + assert.match(mockCallbacks.showError.mock.calls[0].arguments[0], /Failed to update hashtag preferences/); + }); + }); + + describe("persistPreferencesFromMenu", () => { + it("should call service.publish", async () => { + await controller.persistPreferencesFromMenu(); + assert.strictEqual(mockHashtagPreferences.publish.mock.calls.length, 1); + }); + + it("should reuse in-flight promise", async () => { + let resolvePublish; + const slowPublish = new Promise((resolve) => { resolvePublish = resolve; }); + mockHashtagPreferences.publish = mock.fn(() => slowPublish); + + const p1 = controller.persistPreferencesFromMenu(); + // Ensure p1 is set before calling again + assert.strictEqual(controller.publishInFlight, true); + + const p2 = controller.persistPreferencesFromMenu(); + + // Wait for p1 to finish, but we want to assert equality of promises returned. + // Note: p1 is the result of async IIFE. + // Inside controller: + // this.publishPromise = publishPromise; + // return publishPromise; + + assert.strictEqual(p1, p2); + assert.strictEqual(mockHashtagPreferences.publish.mock.calls.length, 1); + + resolvePublish(true); + await p1; + }); + }); +});