mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- Add fallback mock to `js/nostrToolsBootstrap.js` for test environments. - Add guards to `js/ui/components/UploadModal.js` to prevent stale state updates. - Ensure service calls are awaited in `js/ui/tagPreferenceMenuController.js`. - Fix `tests/ui/uploadModal-reset.test.mjs` setup and environment. Co-authored-by: PR0M3TH3AN <[email protected]>
358 lines
9.3 KiB
JavaScript
358 lines
9.3 KiB
JavaScript
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 = await service.addInterest(tag);
|
|
break;
|
|
case TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST:
|
|
result = await service.removeInterest(tag);
|
|
break;
|
|
case TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST:
|
|
result = await service.addDisinterest(tag);
|
|
break;
|
|
case TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST:
|
|
result = await 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();
|
|
}
|
|
}
|
|
}
|