mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Merge pull request #1363 from PR0M3TH3AN/ai/refactor-tag-preference-menu-20231027-11758630250052851478
Refactor TagPreferenceMenu logic to controller
This commit is contained in:
3 files changed
+584
-325
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user