diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 603b3e50..ace8cdd7 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -4,6 +4,7 @@ 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"; export class VideoModal { constructor({ @@ -54,6 +55,8 @@ export class VideoModal { this.videoTimestamp = null; this.videoEditedTimestamp = null; this.videoViewCountEl = null; + this.videoTagsRoot = null; + this.videoTagsData = Object.freeze([]); this.creatorAvatar = null; this.creatorName = null; this.creatorNpub = null; @@ -123,6 +126,7 @@ export class VideoModal { this.handleCreatorNavigation = this.handleCreatorNavigation.bind(this); this.handleModalMoreButtonClick = this.handleModalMoreButtonClick.bind(this); this.handleReactionClick = this.handleReactionClick.bind(this); + this.handleVideoTagActivate = this.handleVideoTagActivate.bind(this); this.MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif"; } @@ -210,6 +214,8 @@ export class VideoModal { } } else { this.loaded = false; + this.cleanupVideoTags(); + this.videoTagsRoot = null; this.playerModal = null; } } @@ -290,6 +296,20 @@ export class VideoModal { this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; this.scrollRegion = this.modalPanel || playerModal; + const nextVideoTagsRoot = playerModal.querySelector("#videoTags") || null; + if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) { + this.cleanupVideoTags(this.videoTagsRoot); + } + this.videoTagsRoot = nextVideoTagsRoot; + if (this.videoTagsRoot) { + this.cleanupVideoTags(this.videoTagsRoot); + } + + const previousTags = Array.isArray(this.videoTagsData) + ? [...this.videoTagsData] + : []; + this.videoTagsData = null; + this.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; this.modalProgress = playerModal.querySelector("#modalProgress") || null; @@ -331,6 +351,8 @@ export class VideoModal { this.reactionCountLabels["-"] = playerModal.querySelector("[data-reaction-dislike-count]") || null; + this.renderVideoTags(previousTags); + this.ambientCanvas = playerModal.querySelector("#ambientCanvas") || null; this.modalZapDialog = playerModal.querySelector("#modalZapDialog") || null; @@ -1031,6 +1053,7 @@ export class VideoModal { if (this.modalMorePopover?.close) { this.modalMorePopover.close({ restoreFocus: false }); } + this.renderVideoTags([]); this.forceRemovePoster("close"); } @@ -1106,6 +1129,151 @@ export class VideoModal { } } + cleanupVideoTags(root = this.videoTagsRoot) { + if (!root) { + return; + } + + const buttons = root.querySelectorAll("button"); + buttons.forEach((button) => { + const handler = button.__tagPillClickHandler; + if (handler) { + button.removeEventListener("click", handler); + delete button.__tagPillClickHandler; + } + }); + + root.textContent = ""; + } + + toggleVideoTagsVisibility(isVisible) { + const root = this.videoTagsRoot; + if (!root) { + return; + } + + if (isVisible) { + root.classList.remove("hidden"); + root.removeAttribute("hidden"); + root.setAttribute("aria-hidden", "false"); + return; + } + + root.classList.add("hidden"); + root.setAttribute("hidden", ""); + root.setAttribute("aria-hidden", "true"); + } + + normalizeVideoTags(tags) { + if (!tags) { + return []; + } + + let iterable; + if (Array.isArray(tags)) { + iterable = tags; + } else if (typeof tags === "string") { + iterable = [tags]; + } else if (typeof tags?.[Symbol.iterator] === "function") { + iterable = Array.from(tags); + } else { + iterable = []; + } + + const seen = new Set(); + const normalized = []; + + for (const entry of iterable) { + if (entry === null || entry === undefined) { + continue; + } + + const raw = typeof entry === "string" ? entry : String(entry ?? ""); + const trimmed = raw.trim().replace(/^#+/, ""); + + if (!trimmed) { + continue; + } + + const key = trimmed.toLocaleLowerCase(); + if (seen.has(key)) { + continue; + } + + seen.add(key); + normalized.push(trimmed); + } + + return normalized.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + } + + areVideoTagsEqual(nextTags, previousTags) { + if (!Array.isArray(nextTags) || !Array.isArray(previousTags)) { + return false; + } + + if (nextTags.length !== previousTags.length) { + return false; + } + + for (let index = 0; index < nextTags.length; index += 1) { + if (nextTags[index] !== previousTags[index]) { + return false; + } + } + + return true; + } + + renderVideoTags(tags = []) { + const normalized = this.normalizeVideoTags(tags); + const hasTags = normalized.length > 0; + const root = this.videoTagsRoot; + + if (root) { + if (this.areVideoTagsEqual(normalized, this.videoTagsData)) { + this.toggleVideoTagsVisibility(hasTags); + return; + } + + this.cleanupVideoTags(root); + + if (hasTags) { + const { root: strip } = renderTagPillStrip({ + document: root.ownerDocument || this.document, + tags: normalized, + onTagActivate: this.handleVideoTagActivate, + }); + + if (strip) { + root.appendChild(strip); + } + } + + this.toggleVideoTagsVisibility(hasTags); + } + + this.videoTagsData = Object.freeze([...normalized]); + } + + handleVideoTagActivate(tag, { event, button } = {}) { + const normalized = + typeof tag === "string" ? tag : String(tag ?? ""); + + if (!normalized) { + return; + } + + this.dispatch("tag:activate", { + tag: normalized, + video: this.activeVideo, + trigger: button || null, + nativeEvent: event || null, + }); + } + updateReactionMeterDisplay() { if (!this.reactionState) { return; @@ -1881,7 +2049,8 @@ export class VideoModal { timestamp, timestamps, viewCount, - creator + creator, + tags, } = {}) { if (this.videoTitle && title !== undefined) { this.videoTitle.textContent = title || "Untitled"; @@ -1889,6 +2058,9 @@ export class VideoModal { if (this.videoDescription && description !== undefined) { this.renderVideoDescription(description); } + if (tags !== undefined) { + this.renderVideoTags(tags ?? []); + } if (timestamps) { this.updateTimestamps(timestamps); } else if (timestamp !== undefined) {