From 2b7097b5f2717a699321c050aacca7518d0d514d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:25:46 -0400 Subject: [PATCH] Add tag preference menu and state handling --- css/tailwind.source.css | 8 + js/app.js | 415 +++++++++++++++++++++++++- js/ui/components/VideoModal.js | 43 ++- js/ui/components/tagPillList.js | 63 +++- js/ui/components/tagPreferenceMenu.js | 254 ++++++++++++++++ js/ui/views/VideoListView.js | 83 +++++- tests/ui/tag-pill-list.test.mjs | 22 +- tests/ui/tag-preference-menu.test.mjs | 86 ++++++ 8 files changed, 958 insertions(+), 16 deletions(-) create mode 100644 js/ui/components/tagPreferenceMenu.js create mode 100644 tests/ui/tag-preference-menu.test.mjs diff --git a/css/tailwind.source.css b/css/tailwind.source.css index cbfc3c86..d17da548 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2272,6 +2272,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; diff --git a/js/app.js b/js/app.js index bf514547..6ae07d9b 100644 --- a/js/app.js +++ b/js/app.js @@ -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"; @@ -249,6 +253,7 @@ class Application { this.boundVideoModalCommentLoadMoreHandler = null; this.boundVideoModalCommentLoginHandler = null; this.boundVideoModalCommentMuteHandler = null; + this.boundVideoModalTagActivateHandler = null; this.pendingModalZapOpen = false; this.videoListViewPlaybackHandler = null; this.videoListViewEditHandler = null; @@ -788,6 +793,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, @@ -834,6 +847,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 = @@ -1067,6 +1100,7 @@ class Application { }); this.moreMenuController.setVideoModal(this.videoModal); this.videoSettingsPopovers = new Map(); + this.tagPreferencePopovers = new Map(); // Hide/Show Subscriptions Link this.subscriptionsLink = null; @@ -1236,6 +1270,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, @@ -3714,6 +3766,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) @@ -3800,6 +3890,8 @@ class Application { handleHashtagPreferencesChange(detail = {}) { const { changed } = this.updateCachedHashtagPreferences(detail); + this.refreshTagPreferenceUi(); + if ( this.profileController && typeof this.profileController.handleHashtagPreferencesChange === @@ -3861,6 +3953,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 || @@ -7384,6 +7533,7 @@ class Application { } this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false }); + this.closeTagPreferenceMenus({ restoreFocus: options?.restoreFocus !== false }); } attachMoreMenuHandlers(container) { @@ -7577,6 +7727,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(); @@ -10141,6 +10522,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", @@ -10241,18 +10629,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) { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index b1dcd85b..a4f910ef 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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; diff --git a/js/ui/components/tagPillList.js b/js/ui/components/tagPillList.js index 3348987c..fdc977c3 100644 --- a/js/ui/components/tagPillList.js +++ b/js/ui/components/tagPillList.js @@ -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); diff --git a/js/ui/components/tagPreferenceMenu.js b/js/ui/components/tagPreferenceMenu.js new file mode 100644 index 00000000..b56a3bbb --- /dev/null +++ b/js/ui/components/tagPreferenceMenu.js @@ -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; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 7202dc57..be2afc5f 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -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,8 +189,11 @@ export class VideoListView { delete: null, blacklist: null, moderationOverride: null, + tagActivate: null, }; + this.tagPreferenceStateResolver = null; + this.allowNsfw = allowNsfw !== false; this.designSystem = normalizeDesignSystemContext(designSystem); @@ -292,6 +298,15 @@ export class VideoListView { this.handlers.moderationOverride = 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) { if (this.popularTagsRoot && this.popularTagsRoot !== container) { this.popularTagsRoot.textContent = ""; @@ -370,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; @@ -1223,4 +1244,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); + } } diff --git a/tests/ui/tag-pill-list.test.mjs b/tests/ui/tag-pill-list.test.mjs index 51f1d026..ce9f6968 100644 --- a/tests/ui/tag-pill-list.test.mjs +++ b/tests/ui/tag-pill-list.test.mjs @@ -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("").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("").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("").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 })); diff --git a/tests/ui/tag-preference-menu.test.mjs b/tests/ui/tag-preference-menu.test.mjs new file mode 100644 index 00000000..3ce49ca0 --- /dev/null +++ b/tests/ui/tag-preference-menu.test.mjs @@ -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("").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("").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("").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"); +});