Merge pull request #895 from PR0M3TH3AN/codex/extend-tag-pill-rendering-and-add-menu-component

Add tag preference menu integration for tag pills
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-25 17:33:15 -04:00
commit d5f2539b56
8 files changed
+956 -19

No files matched your search

+8
View File
@@ -2342,6 +2342,14 @@
@apply px-2 py-0.5 text-2xs;
}
.video-tag-pill[data-preference-state="interest"] {
@apply border-status-success-border bg-status-success-surface text-status-success-on;
}
.video-tag-pill[data-preference-state="disinterest"] {
@apply border-status-danger-border bg-status-danger-surface text-status-danger-on;
}
.video-tag-pill__label {
@apply transition-opacity duration-150 ease-in-out;
opacity: 1;
+407 -8
View File
@@ -95,6 +95,10 @@ import {
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
import { VideoListView } from "./ui/views/VideoListView.js";
import createTagPreferenceMenu, {
TAG_PREFERENCE_ACTIONS,
applyTagPreferenceMenuState,
} from "./ui/components/tagPreferenceMenu.js";
import MoreMenuController from "./ui/moreMenuController.js";
import ProfileModalController from "./ui/profileModalController.js";
import LoginModalController from "./ui/loginModalController.js";
@@ -250,6 +254,7 @@ class Application {
this.boundVideoModalCommentLoadMoreHandler = null;
this.boundVideoModalCommentLoginHandler = null;
this.boundVideoModalCommentMuteHandler = null;
this.boundVideoModalTagActivateHandler = null;
this.pendingModalZapOpen = false;
this.videoListViewPlaybackHandler = null;
this.videoListViewEditHandler = null;
@@ -789,6 +794,14 @@ class Application {
) {
this.videoModal.setMediaLoader(this.mediaLoader);
}
if (
this.videoModal &&
typeof this.videoModal.setTagPreferenceStateResolver === "function"
) {
this.videoModal.setTagPreferenceStateResolver((tag) =>
this.getTagPreferenceState(tag),
);
}
this.zapController = new ZapController({
videoModal: this.videoModal,
getCurrentVideo: () => this.currentVideo,
@@ -835,6 +848,26 @@ class Application {
"video:share",
this.boundVideoModalShareHandler
);
this.boundVideoModalTagActivateHandler = (event) => {
const detail = event?.detail || {};
const nativeEvent = detail?.nativeEvent || null;
if (nativeEvent) {
nativeEvent.preventDefault?.();
nativeEvent.stopPropagation?.();
}
this.handleTagPreferenceActivation({
tag: detail?.tag,
trigger: detail?.trigger || null,
context: "modal",
video: detail?.video || this.currentVideo || null,
event: nativeEvent,
});
};
this.videoModal.addEventListener(
"tag:activate",
this.boundVideoModalTagActivateHandler,
);
this.boundVideoModalSimilarSelectHandler = (event) => {
const detail = event?.detail || {};
const selectedVideo =
@@ -1068,6 +1101,7 @@ class Application {
});
this.moreMenuController.setVideoModal(this.videoModal);
this.videoSettingsPopovers = new Map();
this.tagPreferencePopovers = new Map();
// Hide/Show Subscriptions Link
this.subscriptionsLink = null;
@@ -1237,6 +1271,24 @@ class Application {
: ui.videoListView) ||
new VideoListView(videoListViewConfig);
if (
this.videoListView &&
typeof this.videoListView.setTagPreferenceStateResolver === "function"
) {
this.videoListView.setTagPreferenceStateResolver((tag) =>
this.getTagPreferenceState(tag),
);
}
if (
this.videoListView &&
typeof this.videoListView.setTagActivationHandler === "function"
) {
this.videoListView.setTagActivationHandler((detail = {}) =>
this.handleTagPreferenceActivation(detail),
);
}
this.videoListViewPlaybackHandler = ({
videoId,
url,
@@ -3718,6 +3770,44 @@ class Application {
return normalized;
}
normalizeTagPreferenceCandidate(tag) {
if (typeof tag !== "string") {
return "";
}
const trimmed = tag.trim().replace(/^#+/, "");
if (!trimmed) {
return "";
}
return trimmed.toLowerCase();
}
getTagPreferenceState(tag) {
const normalized = this.normalizeTagPreferenceCandidate(tag);
if (!normalized) {
return "neutral";
}
const { interests, disinterests } = this.getHashtagPreferences();
if (interests.includes(normalized)) {
return "interest";
}
if (disinterests.includes(normalized)) {
return "disinterest";
}
return "neutral";
}
getTagPreferenceMembership(tag) {
const state = this.getTagPreferenceState(tag);
return {
state,
interest: state === "interest",
disinterest: state === "disinterest",
};
}
createHashtagPreferencesSnapshot(detail = {}) {
const service = this.hashtagPreferences;
const sourceInterests = Array.isArray(detail?.interests)
@@ -3804,6 +3894,8 @@ class Application {
handleHashtagPreferencesChange(detail = {}) {
const { changed } = this.updateCachedHashtagPreferences(detail);
this.refreshTagPreferenceUi();
if (
this.profileController &&
typeof this.profileController.handleHashtagPreferencesChange ===
@@ -3865,6 +3957,63 @@ class Application {
};
}
refreshTagPreferenceUi() {
if (this.videoModal?.refreshTagPreferenceStates) {
try {
this.videoModal.refreshTagPreferenceStates();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh video modal tag preference states:",
error,
);
}
}
if (this.videoListView?.refreshTagPreferenceStates) {
try {
this.videoListView.refreshTagPreferenceStates();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh list view tag preference states:",
error,
);
}
}
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,
);
}
});
}
async loadHashtagPreferencesForPubkey(pubkey) {
if (
!this.hashtagPreferences ||
@@ -7472,6 +7621,7 @@ class Application {
}
this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false });
this.closeTagPreferenceMenus({ restoreFocus: options?.restoreFocus !== false });
}
attachMoreMenuHandlers(container) {
@@ -7665,6 +7815,237 @@ 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 = {}) => {
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;
}
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),
);
}
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;
}
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();
if (typeof detail?.closePopover === "function") {
detail.closePopover({ restoreFocus: false });
}
}
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,
});
}
syncModalMoreMenuData() {
if (this.moreMenuController) {
this.moreMenuController.syncModalMoreMenuData();
@@ -10229,6 +10610,13 @@ class Application {
);
this.boundVideoModalShareHandler = null;
}
if (this.boundVideoModalTagActivateHandler) {
this.videoModal.removeEventListener(
"tag:activate",
this.boundVideoModalTagActivateHandler,
);
this.boundVideoModalTagActivateHandler = null;
}
if (this.boundVideoModalSimilarSelectHandler) {
this.videoModal.removeEventListener(
"similar:select",
@@ -10329,18 +10717,29 @@ class Application {
}
}
this.closeTagPreferenceMenus({ restoreFocus: false });
if (this.tagPreferencePopovers) {
this.tagPreferencePopovers.clear();
}
if (this.videoListView) {
if (this.moreMenuController) {
this.moreMenuController.detachVideoListView();
}
this.videoListView.setPlaybackHandler(null);
this.videoListView.setEditHandler(null);
this.videoListView.setRevertHandler(null);
this.videoListView.setDeleteHandler(null);
this.videoListViewPlaybackHandler = null;
this.videoListViewEditHandler = null;
this.videoListViewRevertHandler = null;
this.videoListViewDeleteHandler = null;
this.videoListView.setPlaybackHandler(null);
this.videoListView.setEditHandler(null);
this.videoListView.setRevertHandler(null);
this.videoListView.setDeleteHandler(null);
if (typeof this.videoListView.setTagPreferenceStateResolver === "function") {
this.videoListView.setTagPreferenceStateResolver(null);
}
if (typeof this.videoListView.setTagActivationHandler === "function") {
this.videoListView.setTagActivationHandler(null);
}
this.videoListViewPlaybackHandler = null;
this.videoListViewEditHandler = null;
this.videoListViewRevertHandler = null;
this.videoListViewDeleteHandler = null;
try {
this.videoListView.destroy();
} catch (error) {
+42 -1
View File
@@ -4,7 +4,10 @@ import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js";
import { attachAmbientBackground } from "../ambientBackground.js";
import { applyDesignSystemAttributes } from "../../designSystem.js";
import { devLogger } from "../../utils/logger.js";
import { renderTagPillStrip } from "./tagPillList.js";
import {
renderTagPillStrip,
applyTagPreferenceState,
} from "./tagPillList.js";
import { SimilarContentCard } from "./SimilarContentCard.js";
import {
subscribeToVideoViewCount,
@@ -231,6 +234,7 @@ export class VideoModal {
this.detachAmbientBackground = null;
this.activeVideo = null;
this.tagPreferenceStateResolver = null;
this.handleCopyRequest = this.handleCopyRequest.bind(this);
this.handleShareRequest = this.handleShareRequest.bind(this);
@@ -2854,6 +2858,7 @@ export class VideoModal {
if (root) {
if (this.areVideoTagsEqual(normalized, this.videoTagsData)) {
this.toggleVideoTagsVisibility(hasTags);
this.refreshTagPreferenceStates();
return;
}
@@ -2864,6 +2869,7 @@ export class VideoModal {
document: root.ownerDocument || this.document,
tags: normalized,
onTagActivate: this.handleVideoTagActivate,
getTagState: (tag) => this.resolveTagPreferenceState(tag),
});
if (strip) {
@@ -2893,6 +2899,41 @@ export class VideoModal {
});
}
setTagPreferenceStateResolver(resolver) {
this.tagPreferenceStateResolver =
typeof resolver === "function" ? resolver : null;
}
resolveTagPreferenceState(tag) {
if (typeof this.tagPreferenceStateResolver !== "function") {
return "neutral";
}
try {
return this.tagPreferenceStateResolver(tag);
} catch (error) {
devLogger.warn(
"[VideoModal] Failed to resolve tag preference state:",
error,
);
return "neutral";
}
}
refreshTagPreferenceStates() {
const root = this.videoTagsRoot;
if (!root) {
return;
}
const buttons = root.querySelectorAll("button[data-tag]");
buttons.forEach((button) => {
const tag = button.dataset.tag || "";
const state = this.resolveTagPreferenceState(tag);
applyTagPreferenceState(button, state);
});
}
updateReactionMeterDisplay() {
if (!this.reactionState) {
return;
+59 -4
View File
@@ -22,7 +22,35 @@ function assertDocument(doc) {
}
}
function createTagButton({ doc, tag, onTagActivate }) {
const TAG_PREFERENCE_STATES = new Set(["interest", "disinterest"]);
function normalizeTagPreferenceState(state) {
if (typeof state !== "string") {
return "neutral";
}
const trimmed = state.trim().toLowerCase();
if (TAG_PREFERENCE_STATES.has(trimmed)) {
return trimmed;
}
return "neutral";
}
function applyPreferenceState(button, state) {
if (!button || typeof button !== "object" || button.nodeType !== 1) {
return;
}
const normalized = normalizeTagPreferenceState(state);
button.dataset.preferenceState = normalized;
}
export function applyTagPreferenceState(button, state) {
applyPreferenceState(button, state);
}
function createTagButton({ doc, tag, onTagActivate, getTagState }) {
const button = doc.createElement("button");
button.type = "button";
button.classList.add("pill", "video-tag-pill", "focus-ring");
@@ -57,6 +85,17 @@ function createTagButton({ doc, tag, onTagActivate }) {
button.append(label, icon);
if (getTagState) {
try {
const state = getTagState(tag);
applyPreferenceState(button, state);
} catch (error) {
// Ignore state resolution errors; leave button in neutral state.
}
} else {
applyPreferenceState(button, null);
}
if (onTagActivate) {
const handler = (event) => {
onTagActivate(tag, { event, button });
@@ -80,7 +119,12 @@ function cleanupExistingButtons(root) {
root.textContent = "";
}
export function renderTagPillStrip({ document: doc, tags = [], onTagActivate } = {}) {
export function renderTagPillStrip({
document: doc,
tags = [],
onTagActivate,
getTagState,
} = {}) {
const resolvedDocument = resolveDocument(doc);
assertDocument(resolvedDocument);
@@ -88,7 +132,12 @@ export function renderTagPillStrip({ document: doc, tags = [], onTagActivate } =
root.classList.add("video-tag-strip");
const buttons = tags.map((tag) =>
createTagButton({ doc: resolvedDocument, tag, onTagActivate }),
createTagButton({
doc: resolvedDocument,
tag,
onTagActivate,
getTagState,
}),
);
root.append(...buttons);
@@ -99,6 +148,7 @@ export function updateTagPillStrip({
root,
tags = [],
onTagActivate,
getTagState,
document: doc,
} = {}) {
if (!root) {
@@ -111,7 +161,12 @@ export function updateTagPillStrip({
cleanupExistingButtons(root);
const buttons = tags.map((tag) =>
createTagButton({ doc: resolvedDocument, tag, onTagActivate }),
createTagButton({
doc: resolvedDocument,
tag,
onTagActivate,
getTagState,
}),
);
root.append(...buttons);
+254
View File
@@ -0,0 +1,254 @@
import { normalizeDesignSystemContext } from "../../designSystem.js";
export const TAG_PREFERENCE_ACTIONS = {
ADD_INTEREST: "add-interest",
REMOVE_INTEREST: "remove-interest",
ADD_DISINTEREST: "add-disinterest",
REMOVE_DISINTEREST: "remove-disinterest",
};
function resolveDocument(documentRef) {
if (documentRef && documentRef.nodeType === 9) {
return documentRef;
}
if (typeof document !== "undefined" && document?.nodeType === 9) {
return document;
}
return null;
}
function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) {
const el = doc.createElement(tagName);
classNames
.filter((name) => typeof name === "string" && name.trim())
.forEach((name) => el.classList.add(name));
Object.entries(attrs).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
return;
}
el.setAttribute(key, value);
});
if (typeof textContent === "string") {
el.textContent = textContent;
}
return el;
}
function ensureMenuContainer(doc, panelClassNames = []) {
const panel = createElement(doc, "div", {
classNames: ["popover__panel", ...panelClassNames],
attrs: { role: "menu" },
});
const list = createElement(doc, "div", {
classNames: ["menu"],
attrs: { role: "none" },
});
panel.appendChild(list);
return { panel, list };
}
function appendMenuHeading(doc, list, text) {
const heading = createElement(doc, "div", {
classNames: ["menu__heading"],
textContent: text,
});
list.appendChild(heading);
return heading;
}
function appendMenuAction(doc, list, { text, action, dataset = {}, disabled = false }) {
const button = createElement(doc, "button", {
classNames: ["menu__item", "justify-start"],
textContent: text,
attrs: { type: "button", role: "menuitem" },
});
button.dataset.action = action;
Object.entries(dataset).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
button.dataset[key] = String(value);
});
if (disabled) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
}
list.appendChild(button);
return button;
}
function normalizeTag(tag) {
if (typeof tag !== "string") {
return { label: "", normalized: "" };
}
const trimmed = tag.trim();
if (!trimmed) {
return { label: "", normalized: "" };
}
const withoutHash = trimmed.replace(/^#+/, "");
const normalized = withoutHash.toLowerCase();
return {
label: withoutHash,
normalized,
};
}
function resolveMembership(membership = {}) {
const state =
typeof membership.state === "string" ? membership.state.trim().toLowerCase() : "";
const interestFlag = membership.interest === true || state === "interest";
const disinterestFlag = membership.disinterest === true || state === "disinterest";
if (interestFlag && disinterestFlag) {
// Favor the explicit state value when both flags are set to avoid conflicting UI states.
if (state === "interest") {
return { interest: true, disinterest: false };
}
if (state === "disinterest") {
return { interest: false, disinterest: true };
}
}
return {
interest: Boolean(interestFlag && !disinterestFlag),
disinterest: Boolean(disinterestFlag && !interestFlag),
};
}
function setButtonDisabled(button, disabled) {
if (!button) {
return;
}
if (disabled) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
} else {
button.disabled = false;
button.removeAttribute("aria-disabled");
}
}
export function applyTagPreferenceMenuState({
buttons = {},
membership = {},
isLoggedIn = false,
}) {
const resolvedMembership = resolveMembership(membership);
const disableAll = !isLoggedIn;
setButtonDisabled(buttons.addInterest, disableAll || resolvedMembership.interest);
setButtonDisabled(buttons.removeInterest, disableAll || !resolvedMembership.interest);
setButtonDisabled(
buttons.addDisinterest,
disableAll || resolvedMembership.disinterest,
);
setButtonDisabled(
buttons.removeDisinterest,
disableAll || !resolvedMembership.disinterest,
);
}
export function createTagPreferenceMenu({
document: documentRef = null,
tag = "",
isLoggedIn = false,
membership = {},
onAction = null,
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const { label, normalized } = normalizeTag(tag);
const headingLabel = label ? `#${label}` : "Tag preferences";
const { panel, list } = ensureMenuContainer(doc, ["w-56", "p-0"]);
panel.dataset.menu = "tag-preference";
if (normalized) {
panel.dataset.tag = normalized;
}
appendMenuHeading(doc, list, headingLabel);
if (!isLoggedIn) {
const message = createElement(doc, "p", {
classNames: ["px-4", "pb-2", "text-xs", "text-muted"],
textContent: "Sign in to personalize your recommendations.",
});
list.appendChild(message);
}
const buttons = {};
const actionDataset = { tag: label || normalized };
buttons.addInterest = appendMenuAction(doc, list, {
text: "Add to interests",
action: TAG_PREFERENCE_ACTIONS.ADD_INTEREST,
dataset: actionDataset,
});
buttons.removeInterest = appendMenuAction(doc, list, {
text: "Remove from interests",
action: TAG_PREFERENCE_ACTIONS.REMOVE_INTEREST,
dataset: actionDataset,
});
buttons.addDisinterest = appendMenuAction(doc, list, {
text: "Add to disinterests",
action: TAG_PREFERENCE_ACTIONS.ADD_DISINTEREST,
dataset: actionDataset,
});
buttons.removeDisinterest = appendMenuAction(doc, list, {
text: "Remove from disinterests",
action: TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST,
dataset: actionDataset,
});
applyTagPreferenceMenuState({ buttons, membership, isLoggedIn });
const handleAction = (action, event, button) => {
if (typeof onAction !== "function") {
return;
}
try {
onAction(action, {
tag: label || normalized,
normalizedTag: normalized,
event,
button,
});
} catch (error) {
// Surface errors to callers by rethrowing to keep console visibility in dev mode.
throw error;
}
};
Object.entries(buttons).forEach(([, button]) => {
if (!button) {
return;
}
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const action = button.dataset.action;
handleAction(action, event, button);
});
});
return { panel, buttons };
}
export default createTagPreferenceMenu;
+79 -5
View File
@@ -1,5 +1,8 @@
import { VideoCard } from "../components/VideoCard.js";
import { renderTagPillStrip } from "../components/tagPillList.js";
import {
renderTagPillStrip,
applyTagPreferenceState,
} from "../components/tagPillList.js";
import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
@@ -186,9 +189,11 @@ export class VideoListView {
delete: null,
blacklist: null,
moderationOverride: null,
moderationHide: null,
tagActivate: null,
};
this.tagPreferenceStateResolver = null;
this.allowNsfw = allowNsfw !== false;
this.designSystem = normalizeDesignSystemContext(designSystem);
@@ -293,8 +298,13 @@ export class VideoListView {
this.handlers.moderationOverride = typeof handler === "function" ? handler : null;
}
setModerationHideHandler(handler) {
this.handlers.moderationHide = typeof handler === "function" ? handler : null;
setTagActivationHandler(handler) {
this.handlers.tagActivate = typeof handler === "function" ? handler : null;
}
setTagPreferenceStateResolver(resolver) {
this.tagPreferenceStateResolver =
typeof resolver === "function" ? resolver : null;
}
setPopularTagsContainer(container) {
@@ -375,7 +385,13 @@ export class VideoListView {
return;
}
const { root: strip } = renderTagPillStrip({ document: doc, tags });
const { root: strip } = renderTagPillStrip({
document: doc,
tags,
onTagActivate: (tag, detail = {}) =>
this.handlePopularTagActivate(tag, detail),
getTagState: (tag) => this.resolveTagPreferenceState(tag),
});
root.textContent = "";
root.appendChild(strip);
root.hidden = false;
@@ -1241,4 +1257,62 @@ export class VideoListView {
const detail = this.extractPlaybackDetail(trigger, null);
this.emitSelected(detail);
}
resolveTagPreferenceState(tag) {
const resolver = this.tagPreferenceStateResolver;
if (typeof resolver !== "function") {
return "neutral";
}
try {
return resolver(tag);
} catch (error) {
userLogger.warn(
"[VideoListView] Failed to resolve tag preference state:",
error,
);
return "neutral";
}
}
refreshTagPreferenceStates() {
if (!this._popularTagStrip) {
return;
}
const buttons = this._popularTagStrip.querySelectorAll("button[data-tag]");
buttons.forEach((button) => {
const tag = button.dataset.tag || "";
const state = this.resolveTagPreferenceState(tag);
applyTagPreferenceState(button, state);
});
}
handlePopularTagActivate(tag, { event = null, button = null } = {}) {
const normalizedTag =
typeof tag === "string" && tag.trim() ? tag.trim() : String(tag ?? "");
if (!normalizedTag) {
return;
}
const detail = {
tag: normalizedTag,
event,
trigger: button,
context: "popular-tags",
};
if (typeof this.handlers.tagActivate === "function") {
try {
this.handlers.tagActivate(detail);
} catch (error) {
userLogger.warn(
"[VideoListView] Tag activation handler threw an error:",
error,
);
}
}
this.emit("tag:activate", detail);
}
}
+21 -1
View File
@@ -2,7 +2,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { renderTagPillStrip, updateTagPillStrip } from "../../js/ui/components/tagPillList.js";
import {
renderTagPillStrip,
updateTagPillStrip,
} from "../../js/ui/components/tagPillList.js";
test("renderTagPillStrip builds buttons with labels and icons", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
@@ -25,6 +28,7 @@ test("renderTagPillStrip builds buttons with labels and icons", () => {
assert.equal(button.dataset.size, "compact");
assert.equal(button.dataset.tag, tag);
assert.equal(button.title, `#${tag}`);
assert.equal(button.dataset.preferenceState, "neutral");
const label = button.querySelector(".video-tag-pill__label");
assert(label, "label span should be present");
@@ -40,6 +44,20 @@ test("renderTagPillStrip builds buttons with labels and icons", () => {
}
});
test("renderTagPillStrip applies preference state styling", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
const { buttons } = renderTagPillStrip({
document,
tags: ["nostr", "video"],
getTagState(tag) {
return tag === "nostr" ? "interest" : "disinterest";
},
});
assert.equal(buttons[0].dataset.preferenceState, "interest");
assert.equal(buttons[1].dataset.preferenceState, "disinterest");
});
test("renderTagPillStrip wires the activation callback", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
const activations = [];
@@ -86,6 +104,7 @@ test("updateTagPillStrip replaces buttons and rewires handlers", () => {
onTagActivate(tag, { button }) {
secondActivations.push({ tag, button });
},
getTagState: () => "interest",
});
assert.equal(root.querySelectorAll("button").length, 1);
@@ -93,6 +112,7 @@ test("updateTagPillStrip replaces buttons and rewires handlers", () => {
const updatedIcon = updatedButtons[0].querySelector(".video-tag-pill__icon svg");
assert(updatedIcon, "updated button should contain the svg icon");
assert.equal(updatedButtons[0].dataset.preferenceState, "interest");
// Ensure the old handler is removed by dispatching a click on the detached button.
initialButtons[0].dispatchEvent(new document.defaultView.Event("click", { bubbles: true }));
+86
View File
@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import createTagPreferenceMenu, {
TAG_PREFERENCE_ACTIONS,
applyTagPreferenceMenuState,
} from "../../js/ui/components/tagPreferenceMenu.js";
test("createTagPreferenceMenu renders heading and actions", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
const menu = createTagPreferenceMenu({
document,
tag: "#Nostr",
isLoggedIn: true,
});
assert(menu, "menu should render");
const { panel, buttons } = menu;
assert.equal(panel.dataset.menu, "tag-preference");
assert.equal(panel.dataset.tag, "nostr");
const heading = panel.querySelector(".menu__heading");
assert(heading, "heading should be rendered");
assert.equal(heading.textContent, "#Nostr");
assert.equal(typeof buttons.addInterest, "object");
assert.equal(buttons.addInterest.dataset.action, TAG_PREFERENCE_ACTIONS.ADD_INTEREST);
assert.equal(
buttons.removeDisinterest.dataset.action,
TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST,
);
});
test("createTagPreferenceMenu disables actions based on membership and login", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
const { buttons, panel } = createTagPreferenceMenu({
document,
tag: "video",
isLoggedIn: false,
membership: { state: "interest" },
});
assert(buttons.addInterest.disabled, "add interest should be disabled for interest state");
assert(buttons.removeInterest.disabled, "remove interest should be disabled when logged out");
assert(buttons.addDisinterest.disabled, "add disinterest should require login");
assert(buttons.removeDisinterest.disabled, "remove disinterest should require login");
const message = panel.querySelector("p.text-xs");
assert(message, "login message should be present when logged out");
applyTagPreferenceMenuState({
buttons,
isLoggedIn: true,
membership: { state: "disinterest" },
});
assert.equal(buttons.addInterest.disabled, false);
assert.equal(buttons.removeInterest.disabled, true);
assert.equal(buttons.addDisinterest.disabled, true);
assert.equal(buttons.removeDisinterest.disabled, false);
});
test("createTagPreferenceMenu forwards actions to callback", () => {
const { document } = new JSDOM("<!DOCTYPE html>").window;
const events = [];
const { panel, buttons } = createTagPreferenceMenu({
document,
tag: "art",
isLoggedIn: true,
membership: { state: "disinterest" },
onAction(action, detail) {
events.push({ action, detail });
},
});
document.body.append(panel);
buttons.addInterest.click();
buttons.removeDisinterest.click();
assert.equal(events.length, 2);
assert.equal(events[0].action, TAG_PREFERENCE_ACTIONS.ADD_INTEREST);
assert.equal(events[0].detail.tag, "art");
assert.equal(events[1].action, TAG_PREFERENCE_ACTIONS.REMOVE_DISINTEREST);
assert.equal(events[1].detail.normalizedTag, "art");
});