Merge pull request #1363 from PR0M3TH3AN/ai/refactor-tag-preference-menu-20231027-11758630250052851478

Refactor TagPreferenceMenu logic to controller
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-18 22:55:13 -05:00
commit d1497de067
3 files changed
+584 -325

No files matched your search

+42 -325
View File
@@ -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) {
+357
View File
@@ -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();
}
}
}
@@ -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;
});
});
});