mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
8 files changed
+956
-19
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user