diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js new file mode 100644 index 00000000..56f3a5e2 --- /dev/null +++ b/js/ui/components/hashtagStripHelper.js @@ -0,0 +1,327 @@ +import { userLogger } from "../../utils/logger.js"; +import { collectVideoTags } from "../../utils/videoTags.js"; +import { + applyTagPreferenceState, + renderTagPillStrip, + trimTagPillStripToFit, +} from "./tagPillList.js"; + +export class HashtagStripHelper { + constructor({ document: doc = null, window: win = null, logger } = {}) { + this.document = doc || (typeof document !== "undefined" ? document : null); + this.window = win || this.document?.defaultView || + (typeof window !== "undefined" ? window : null); + this.logger = logger || userLogger; + + this.container = null; + this._tagStrip = null; + this._sortedTags = []; + this._resizeObserver = null; + this._resizeHandler = null; + this._resizeCancel = null; + this._resizeScheduled = false; + this._isApplyingTrim = false; + + this.tagStateResolver = null; + this.activateHandler = null; + + this._handleResize = () => { + if (this._isApplyingTrim) { + return; + } + this.render(); + }; + } + + mount(container) { + if (this.container === container) { + return this.container; + } + + this._teardownResizeObserver(); + + if (this.container && this.container !== container) { + this.container.textContent = ""; + this.container.hidden = true; + } + + this.container = container || null; + this._tagStrip = null; + + if (this.container) { + this.container.textContent = ""; + this.container.hidden = true; + if (this._sortedTags.length) { + this.render(); + this._ensureResizeObserver(); + } + } + + return this.container; + } + + update(videos) { + const root = this.container; + if (!root) { + return; + } + + if (!Array.isArray(videos) || videos.length === 0) { + this._sortedTags = []; + this.render(); + this._teardownResizeObserver(); + return; + } + + const counts = new Map(); + const displayNames = new Map(); + + videos.forEach((video) => { + const tags = collectVideoTags(video); + tags.forEach((tag) => { + if (typeof tag !== "string" || !tag) { + return; + } + const lower = tag.toLowerCase(); + counts.set(lower, (counts.get(lower) || 0) + 1); + if (!displayNames.has(lower)) { + displayNames.set(lower, tag); + } + }); + }); + + if (!counts.size) { + this._sortedTags = []; + this.render(); + this._teardownResizeObserver(); + return; + } + + const tagEntries = Array.from(counts.entries()).map(([lower, count]) => ({ + count, + tag: displayNames.get(lower) || lower, + })); + + tagEntries.sort((a, b) => { + if (b.count !== a.count) { + return b.count - a.count; + } + const lowerA = a.tag.toLowerCase(); + const lowerB = b.tag.toLowerCase(); + if (lowerA === lowerB) { + return a.tag.localeCompare(b.tag); + } + return lowerA.localeCompare(lowerB); + }); + + this._sortedTags = tagEntries.map((entry) => entry.tag); + this.render(); + if (this._sortedTags.length) { + this._ensureResizeObserver(); + } else { + this._teardownResizeObserver(); + } + } + + setTagStateResolver(resolver) { + this.tagStateResolver = typeof resolver === "function" ? resolver : null; + this.refreshTagStates(); + } + + setActivateHandler(handler) { + this.activateHandler = typeof handler === "function" ? handler : null; + } + + refreshTagStates() { + if (!this._tagStrip) { + return; + } + + const buttons = this._tagStrip.querySelectorAll("button[data-tag]"); + buttons.forEach((button) => { + const tag = button.dataset.tag || ""; + const state = this._resolveTagState(tag); + applyTagPreferenceState(button, state); + }); + } + + render() { + const root = this.container; + if (!root) { + return; + } + + const tags = Array.isArray(this._sortedTags) ? [...this._sortedTags] : []; + + this._isApplyingTrim = true; + + try { + root.textContent = ""; + this._tagStrip = null; + + if (!tags.length) { + root.hidden = true; + return; + } + + const doc = this.document || root.ownerDocument || null; + if (!doc) { + root.hidden = true; + this._teardownResizeObserver(); + return; + } + + const { root: strip } = renderTagPillStrip({ + document: doc, + tags, + onTagActivate: (tag, detail = {}) => + this._handleTagActivate(tag, detail), + getTagState: (tag) => this._resolveTagState(tag), + }); + + root.appendChild(strip); + this._tagStrip = strip; + trimTagPillStripToFit({ strip, container: root }); + root.hidden = strip.childElementCount === 0; + } finally { + this._isApplyingTrim = false; + } + } + + destroy() { + this._teardownResizeObserver(); + if (this.container) { + this.container.textContent = ""; + this.container.hidden = true; + } + this.container = null; + this._tagStrip = null; + this._sortedTags = []; + this.tagStateResolver = null; + this.activateHandler = null; + } + + _resolveTagState(tag) { + const resolver = this.tagStateResolver; + if (typeof resolver !== "function") { + return "neutral"; + } + + try { + return resolver(tag); + } catch (error) { + this.logger?.warn?.( + "[HashtagStripHelper] Failed to resolve tag preference state:", + error, + ); + return "neutral"; + } + } + + _handleTagActivate(tag, detail) { + if (typeof this.activateHandler !== "function") { + return; + } + + try { + this.activateHandler(tag, detail); + } catch (error) { + this.logger?.warn?.( + "[HashtagStripHelper] Tag activation handler threw an error:", + error, + ); + } + } + + _ensureResizeObserver() { + const root = this.container; + if (!root || this._resizeObserver || this._resizeHandler) { + return; + } + + const resizeObserverCtor = + (this.window && this.window.ResizeObserver) || + (typeof globalThis !== "undefined" ? globalThis.ResizeObserver : null); + + if (typeof resizeObserverCtor === "function") { + this._resizeObserver = new resizeObserverCtor(() => { + this._handleResize(); + }); + this._resizeObserver.observe(root); + return; + } + + if (!this.window) { + return; + } + + const schedule = (callback) => { + if (this._resizeScheduled) { + return; + } + this._resizeScheduled = true; + + if (typeof this.window.requestAnimationFrame === "function") { + const frameId = this.window.requestAnimationFrame(() => { + this._resizeScheduled = false; + this._resizeCancel = null; + callback(); + }); + this._resizeCancel = () => { + this.window.cancelAnimationFrame(frameId); + this._resizeCancel = null; + this._resizeScheduled = false; + }; + } else { + const timeoutId = this.window.setTimeout(() => { + this._resizeScheduled = false; + this._resizeCancel = null; + callback(); + }, 50); + this._resizeCancel = () => { + this.window.clearTimeout(timeoutId); + this._resizeCancel = null; + this._resizeScheduled = false; + }; + } + }; + + const handler = () => { + schedule(() => { + if (!this.container) { + return; + } + this._handleResize(); + }); + }; + + this.window.addEventListener("resize", handler); + this._resizeHandler = handler; + } + + _teardownResizeObserver() { + if (this._resizeObserver) { + try { + this._resizeObserver.disconnect(); + } catch (error) { + // Ignore disconnect errors. + } + } + this._resizeObserver = null; + + if (this._resizeHandler && this.window) { + this.window.removeEventListener("resize", this._resizeHandler); + } + this._resizeHandler = null; + + if (typeof this._resizeCancel === "function") { + try { + this._resizeCancel(); + } catch (error) { + // Ignore cancellation errors. + } + } + this._resizeCancel = null; + this._resizeScheduled = false; + } +} diff --git a/js/ui/videoListViewController.js b/js/ui/videoListViewController.js index b190e830..63999d89 100644 --- a/js/ui/videoListViewController.js +++ b/js/ui/videoListViewController.js @@ -17,14 +17,22 @@ export default class VideoListViewController { ); } - mount({ container = null, view, currentVideoList = null, includeTags = true } = {}) { + mount({ + container = null, + popularTagsContainer = null, + view, + currentVideoList = null, + includeTags = true, + } = {}) { if (!view) { return { videoList: currentVideoList || null, popularTags: null }; } const target = container || this.getElementById("videoList"); let popularTags = null; - if (includeTags) { + if (this.isElement(popularTagsContainer)) { + popularTags = popularTagsContainer; + } else if (includeTags) { popularTags = this.getElementById("recentVideoTags"); } @@ -45,6 +53,7 @@ export default class VideoListViewController { reason, postLoginResult, currentVideoList = null, + popularTagsContainer = null, } = {}) { if (!view) { return { @@ -74,7 +83,12 @@ export default class VideoListViewController { ? "Applying your filters…" : "Refreshing videos…"; - const tagsRoot = this.getElementById("recentVideoTags"); + let tagsRoot = null; + if (this.isElement(popularTagsContainer)) { + tagsRoot = popularTagsContainer; + } else { + tagsRoot = this.getElementById("recentVideoTags"); + } if (typeof view.setPopularTagsContainer === "function") { view.setPopularTagsContainer(this.isElement(tagsRoot) ? tagsRoot : null); } diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 4bd5c7ad..e55d487f 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -1,16 +1,11 @@ import { VideoCard } from "../components/VideoCard.js"; -import { - renderTagPillStrip, - applyTagPreferenceState, - trimTagPillStripToFit, -} from "../components/tagPillList.js"; +import { HashtagStripHelper } from "../components/hashtagStripHelper.js"; import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount, } from "../../viewCounter.js"; import { normalizeDesignSystemContext } from "../../designSystem.js"; import { userLogger } from "../../utils/logger.js"; -import { collectVideoTags } from "../../utils/videoTags.js"; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; @@ -180,20 +175,15 @@ export class VideoListView { this.currentVideos = []; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; - this.popularTagsRoot = null; - this._popularTagStrip = null; - this._popularTagsSortedList = []; - this._popularTagResizeObserver = null; - this._popularTagResizeHandler = null; - this._popularTagResizeCancel = null; - this._popularTagResizeScheduled = false; - this._isApplyingPopularTagTrim = false; - this._handlePopularTagResize = () => { - if (this._isApplyingPopularTagTrim) { - return; - } - this.renderPopularTagStrip(); - }; + + this.popularTagHelper = new HashtagStripHelper({ + document: this.document, + window: this.window, + logger: userLogger, + }); + this.popularTagHelper.setActivateHandler((tag, detail) => + this.handlePopularTagActivate(tag, detail), + ); this.handlers = { playback: null, @@ -207,8 +197,6 @@ export class VideoListView { tagActivate: null, }; - this.tagPreferenceStateResolver = null; - this.allowNsfw = allowNsfw !== false; this.designSystem = normalizeDesignSystemContext(designSystem); @@ -281,14 +269,9 @@ export class VideoListView { this.currentVideos = []; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; - this._teardownPopularTagResizeObserver(); - if (this.popularTagsRoot) { - this.popularTagsRoot.textContent = ""; - this.popularTagsRoot.hidden = true; + if (this.popularTagHelper) { + this.popularTagHelper.destroy(); } - this.popularTagsRoot = null; - this._popularTagStrip = null; - this._popularTagsSortedList = []; } setPlaybackHandler(handler) { @@ -330,228 +313,21 @@ export class VideoListView { } setTagPreferenceStateResolver(resolver) { - this.tagPreferenceStateResolver = - typeof resolver === "function" ? resolver : null; + if (this.popularTagHelper) { + this.popularTagHelper.setTagStateResolver(resolver); + } } setPopularTagsContainer(container) { - this._teardownPopularTagResizeObserver(); - if (this.popularTagsRoot && this.popularTagsRoot !== container) { - this.popularTagsRoot.textContent = ""; - this.popularTagsRoot.hidden = true; - } - - this.popularTagsRoot = container || null; - this._popularTagStrip = null; - - if (this.popularTagsRoot) { - this.popularTagsRoot.textContent = ""; - this.popularTagsRoot.hidden = true; - if (this._popularTagsSortedList.length) { - this.renderPopularTagStrip(); - this._ensurePopularTagResizeObserver(); - } + if (this.popularTagHelper) { + this.popularTagHelper.mount(container || null); } } updatePopularTags(videos) { - const root = this.popularTagsRoot; - if (!root) { - return; + if (this.popularTagHelper) { + this.popularTagHelper.update(videos); } - - if (!Array.isArray(videos) || videos.length === 0) { - this._popularTagsSortedList = []; - this.renderPopularTagStrip(); - this._teardownPopularTagResizeObserver(); - return; - } - - const counts = new Map(); - const displayNames = new Map(); - - videos.forEach((video) => { - const tags = collectVideoTags(video); - tags.forEach((tag) => { - if (typeof tag !== "string" || !tag) { - return; - } - const lower = tag.toLowerCase(); - counts.set(lower, (counts.get(lower) || 0) + 1); - if (!displayNames.has(lower)) { - displayNames.set(lower, tag); - } - }); - }); - - if (!counts.size) { - this._popularTagsSortedList = []; - this.renderPopularTagStrip(); - this._teardownPopularTagResizeObserver(); - return; - } - - const tagEntries = Array.from(counts.entries()).map(([lower, count]) => ({ - count, - tag: displayNames.get(lower) || lower, - })); - - tagEntries.sort((a, b) => { - if (b.count !== a.count) { - return b.count - a.count; - } - const lowerA = a.tag.toLowerCase(); - const lowerB = b.tag.toLowerCase(); - if (lowerA === lowerB) { - return a.tag.localeCompare(b.tag); - } - return lowerA.localeCompare(lowerB); - }); - - this._popularTagsSortedList = tagEntries.map((entry) => entry.tag); - this.renderPopularTagStrip(); - if (this._popularTagsSortedList.length) { - this._ensurePopularTagResizeObserver(); - } else { - this._teardownPopularTagResizeObserver(); - } - } - - renderPopularTagStrip() { - const root = this.popularTagsRoot; - if (!root) { - return; - } - - const tags = Array.isArray(this._popularTagsSortedList) - ? [...this._popularTagsSortedList] - : []; - - this._isApplyingPopularTagTrim = true; - - try { - root.textContent = ""; - this._popularTagStrip = null; - - if (!tags.length) { - root.hidden = true; - return; - } - - const doc = this.document || root.ownerDocument || null; - if (!doc) { - root.hidden = true; - this._teardownPopularTagResizeObserver(); - return; - } - - const { root: strip } = renderTagPillStrip({ - document: doc, - tags, - onTagActivate: (tag, detail = {}) => - this.handlePopularTagActivate(tag, detail), - getTagState: (tag) => this.resolveTagPreferenceState(tag), - }); - - root.appendChild(strip); - this._popularTagStrip = strip; - trimTagPillStripToFit({ strip, container: root }); - root.hidden = strip.childElementCount === 0; - } finally { - this._isApplyingPopularTagTrim = false; - } - } - - _ensurePopularTagResizeObserver() { - const root = this.popularTagsRoot; - if (!root || this._popularTagResizeObserver || this._popularTagResizeHandler) { - return; - } - - const resizeObserverCtor = - (this.window && this.window.ResizeObserver) || - (typeof globalThis !== "undefined" ? globalThis.ResizeObserver : null); - - if (typeof resizeObserverCtor === "function") { - this._popularTagResizeObserver = new resizeObserverCtor(() => { - this._handlePopularTagResize(); - }); - this._popularTagResizeObserver.observe(root); - return; - } - - if (!this.window) { - return; - } - - const schedule = (callback) => { - if (this._popularTagResizeScheduled) { - return; - } - this._popularTagResizeScheduled = true; - - if (typeof this.window.requestAnimationFrame === "function") { - const frameId = this.window.requestAnimationFrame(() => { - this._popularTagResizeScheduled = false; - this._popularTagResizeCancel = null; - callback(); - }); - this._popularTagResizeCancel = () => { - this.window.cancelAnimationFrame(frameId); - this._popularTagResizeCancel = null; - this._popularTagResizeScheduled = false; - }; - } else { - const timeoutId = this.window.setTimeout(() => { - this._popularTagResizeScheduled = false; - this._popularTagResizeCancel = null; - callback(); - }, 50); - this._popularTagResizeCancel = () => { - this.window.clearTimeout(timeoutId); - this._popularTagResizeCancel = null; - this._popularTagResizeScheduled = false; - }; - } - }; - - const handler = () => { - schedule(() => { - if (!this.popularTagsRoot) { - return; - } - this._handlePopularTagResize(); - }); - }; - - this.window.addEventListener("resize", handler); - this._popularTagResizeHandler = handler; - } - - _teardownPopularTagResizeObserver() { - if (this._popularTagResizeObserver) { - try { - this._popularTagResizeObserver.disconnect(); - } catch (error) { - // Ignore disconnect errors. - } - } - this._popularTagResizeObserver = null; - - if (this._popularTagResizeHandler && this.window) { - this.window.removeEventListener("resize", this._popularTagResizeHandler); - } - this._popularTagResizeHandler = null; - - if (typeof this._popularTagResizeCancel === "function") { - try { - this._popularTagResizeCancel(); - } catch (error) { - // Ignore cancellation errors. - } - } - this._popularTagResizeCancel = null; - this._popularTagResizeScheduled = false; } render(videos, metadata = null) { @@ -1500,34 +1276,10 @@ export class VideoListView { 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; + if (this.popularTagHelper) { + this.popularTagHelper.refreshTagStates(); } - - 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 } = {}) {