From a3a49742caaeecf346dd22b0dca18c240074cc36 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:44:09 -0500 Subject: [PATCH 01/96] Refactor popular tag strip rendering --- js/ui/components/hashtagStripHelper.js | 327 +++++++++++++++++++++++++ js/ui/videoListViewController.js | 20 +- js/ui/views/VideoListView.js | 290 ++-------------------- 3 files changed, 365 insertions(+), 272 deletions(-) create mode 100644 js/ui/components/hashtagStripHelper.js 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 } = {}) { From f91a35d89ecd8b1f1ffbee89a8548b9d29024748 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:48:56 -0500 Subject: [PATCH 02/96] Update tag strip activation payload --- js/ui/components/hashtagStripHelper.js | 22 +++++++++++++++++-- js/ui/views/VideoListView.js | 29 ++++++++++++++++++-------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index 56f3a5e2..8b0953e2 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -7,11 +7,17 @@ import { } from "./tagPillList.js"; export class HashtagStripHelper { - constructor({ document: doc = null, window: win = null, logger } = {}) { + constructor({ + document: doc = null, + window: win = null, + logger, + context = "", + } = {}) { this.document = doc || (typeof document !== "undefined" ? document : null); this.window = win || this.document?.defaultView || (typeof window !== "undefined" ? window : null); this.logger = logger || userLogger; + this.context = typeof context === "string" ? context : ""; this.container = null; this._tagStrip = null; @@ -132,6 +138,14 @@ export class HashtagStripHelper { this.activateHandler = typeof handler === "function" ? handler : null; } + setContext(context) { + this.context = typeof context === "string" ? context : ""; + } + + refreshTagPreferenceStates() { + this.refreshTagStates(); + } + refreshTagStates() { if (!this._tagStrip) { return; @@ -224,7 +238,11 @@ export class HashtagStripHelper { } try { - this.activateHandler(tag, detail); + this.activateHandler({ + tag, + trigger: detail?.button || null, + context: this.context, + }); } catch (error) { this.logger?.warn?.( "[HashtagStripHelper] Tag activation handler threw an error:", diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index e55d487f..faa8fc77 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -181,8 +181,9 @@ export class VideoListView { window: this.window, logger: userLogger, }); - this.popularTagHelper.setActivateHandler((tag, detail) => - this.handlePopularTagActivate(tag, detail), + this.popularTagHelper.setContext("popular-tags"); + this.popularTagHelper.setActivateHandler((detail) => + this.handlePopularTagActivate(detail), ); this.handlers = { @@ -1278,27 +1279,37 @@ export class VideoListView { refreshTagPreferenceStates() { if (this.popularTagHelper) { - this.popularTagHelper.refreshTagStates(); + this.popularTagHelper.refreshTagPreferenceStates(); } } - handlePopularTagActivate(tag, { event = null, button = null } = {}) { + handlePopularTagActivate(detail = {}, options = {}) { + const candidate = + detail && typeof detail === "object" ? detail : { tag: detail }; + const tag = candidate.tag; + const event = candidate.event || options.event || null; + const trigger = + candidate.trigger || candidate.button || options.button || null; + const context = + typeof candidate.context === "string" && candidate.context + ? candidate.context + : "popular-tags"; const normalizedTag = typeof tag === "string" && tag.trim() ? tag.trim() : String(tag ?? ""); if (!normalizedTag) { return; } - const detail = { + const payload = { tag: normalizedTag, event, - trigger: button, - context: "popular-tags", + trigger, + context, }; if (typeof this.handlers.tagActivate === "function") { try { - this.handlers.tagActivate(detail); + this.handlers.tagActivate(payload); } catch (error) { userLogger.warn( "[VideoListView] Tag activation handler threw an error:", @@ -1307,6 +1318,6 @@ export class VideoListView { } } - this.emit("tag:activate", detail); + this.emit("tag:activate", payload); } } From d7434c5a98cac6f06e1b95cb73a211050fedd9f6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:54:12 -0500 Subject: [PATCH 03/96] Add scrollable tag strip option --- css/tailwind.source.css | 6 ++++++ js/ui/components/hashtagStripHelper.js | 7 ++++++- js/ui/components/tagPillList.js | 4 ++++ js/ui/views/VideoListView.js | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index ef7af0aa..1f77eb25 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2419,6 +2419,12 @@ overflow: hidden; } + .video-tag-strip--scroll { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + } + .video-tag-pill { @apply inline-flex items-center gap-2 rounded-full border border-status-neutral-border bg-status-neutral-surface px-3 py-1 text-2xs font-medium text-status-neutral-on; @apply relative justify-center whitespace-nowrap transition; diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index 8b0953e2..f011caed 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -12,12 +12,14 @@ export class HashtagStripHelper { window: win = null, logger, context = "", + scrollable = false, } = {}) { this.document = doc || (typeof document !== "undefined" ? document : null); this.window = win || this.document?.defaultView || (typeof window !== "undefined" ? window : null); this.logger = logger || userLogger; this.context = typeof context === "string" ? context : ""; + this.scrollable = scrollable === true; this.container = null; this._tagStrip = null; @@ -191,11 +193,14 @@ export class HashtagStripHelper { onTagActivate: (tag, detail = {}) => this._handleTagActivate(tag, detail), getTagState: (tag) => this._resolveTagState(tag), + scrollable: this.scrollable, }); root.appendChild(strip); this._tagStrip = strip; - trimTagPillStripToFit({ strip, container: root }); + if (!this.scrollable) { + trimTagPillStripToFit({ strip, container: root }); + } root.hidden = strip.childElementCount === 0; } finally { this._isApplyingTrim = false; diff --git a/js/ui/components/tagPillList.js b/js/ui/components/tagPillList.js index dc194561..ea89704e 100644 --- a/js/ui/components/tagPillList.js +++ b/js/ui/components/tagPillList.js @@ -213,12 +213,16 @@ export function renderTagPillStrip({ tags = [], onTagActivate, getTagState, + scrollable = false, } = {}) { const resolvedDocument = resolveDocument(doc); assertDocument(resolvedDocument); const root = resolvedDocument.createElement("div"); root.classList.add("video-tag-strip"); + if (scrollable) { + root.classList.add("video-tag-strip--scroll"); + } const buttons = tags.map((tag) => createTagButton({ diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index faa8fc77..e25d329d 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -180,6 +180,7 @@ export class VideoListView { document: this.document, window: this.window, logger: userLogger, + scrollable: true, }); this.popularTagHelper.setContext("popular-tags"); this.popularTagHelper.setActivateHandler((detail) => From 766bf0c872112a9c395cdc6661a5a56a55283250 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:58:12 -0500 Subject: [PATCH 04/96] Refine scrollable tag strip styling --- css/tailwind.source.css | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 1f77eb25..62da5801 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2423,6 +2423,26 @@ overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + padding-bottom: var(--space-2xs); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; + } + + .video-tag-strip--scroll::-webkit-scrollbar { + height: var(--scrollbar-thin-width); + } + + .video-tag-strip--scroll::-webkit-scrollbar-track { + background: transparent; + } + + .video-tag-strip--scroll::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--radius-sm); + } + + .video-tag-strip--scroll::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); } .video-tag-pill { From 711f9bd03190568a03f5f93a8665181873c9de75 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:01:49 -0500 Subject: [PATCH 05/96] Adjust scrollable tag strip scrollbar --- css/tailwind.source.css | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 62da5801..92b9f1b1 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2423,26 +2423,28 @@ overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; - padding-bottom: var(--space-2xs); + padding-bottom: var(--space-xs); scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; } .video-tag-strip--scroll::-webkit-scrollbar { - height: var(--scrollbar-thin-width); + height: 4px; } .video-tag-strip--scroll::-webkit-scrollbar-track { - background: transparent; + background: + linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + var(--scrollbar-track) calc(50% - 0.5px) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); } .video-tag-strip--scroll::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); - border-radius: var(--radius-sm); - } - - .video-tag-strip--scroll::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); + border-radius: var(--radius-pill); } .video-tag-pill { From 5dde681d089b4aaf3328a27aa56c2b83190cf92c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:06:02 -0500 Subject: [PATCH 06/96] Pass click events to tag preference handler --- js/ui/components/hashtagStripHelper.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index f011caed..35d1a849 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -245,6 +245,7 @@ export class HashtagStripHelper { try { this.activateHandler({ tag, + event: detail?.event || null, trigger: detail?.button || null, context: this.context, }); From 475f6786811ef33a1ac40472554ccf87f88d1cb3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:16:24 -0500 Subject: [PATCH 07/96] Harden tag preference activation for popular tags --- js/ui/components/hashtagStripHelper.js | 8 +++++++- js/ui/views/VideoListView.js | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index 35d1a849..6dc2dde5 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -243,10 +243,16 @@ export class HashtagStripHelper { } try { + const trigger = + detail?.button || + detail?.event?.currentTarget || + detail?.event?.target?.closest?.("button[data-tag]") || + null; this.activateHandler({ tag, event: detail?.event || null, - trigger: detail?.button || null, + trigger, + button: trigger, context: this.context, }); } catch (error) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index e25d329d..51871bc9 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -1289,8 +1289,13 @@ export class VideoListView { detail && typeof detail === "object" ? detail : { tag: detail }; const tag = candidate.tag; const event = candidate.event || options.event || null; - const trigger = - candidate.trigger || candidate.button || options.button || null; + let trigger = candidate.trigger || candidate.button || options.button || null; + if (!trigger && event && event.target) { + trigger = + event.target.closest?.("button[data-tag]") || + event.currentTarget || + null; + } const context = typeof candidate.context === "string" && candidate.context ? candidate.context From a87d1353cbf04cc6a0fc3ec93bee132c0e8bb6ab Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:23:19 +0000 Subject: [PATCH 08/96] chore(ai): Triage TODOs - Generated `artifacts/todos.txt` with relevant TODOs found in the codebase. - Verified that no trivial JS source TODOs exist (one false positive in `profileModalController.js`). - Identified flaky test TODOs in `tests/visual/moderation.spec.ts`. --- artifacts/todos.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 artifacts/todos.txt diff --git a/artifacts/todos.txt b/artifacts/todos.txt new file mode 100644 index 00000000..5f3a83bf --- /dev/null +++ b/artifacts/todos.txt @@ -0,0 +1,6 @@ +tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless) +tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless) +tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure +tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure +tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure +tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure From 6ae858ec847e330e66367a72262a36f341580624 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:35:48 +0000 Subject: [PATCH 09/96] fix(ui): preserve handlers in HashtagStripHelper on destroy When navigating away from a view, `VideoListView` calls `destroy()` on its `HashtagStripHelper`. Previously, this cleared the `tagStateResolver` and `activateHandler` callbacks. Since `VideoListView` is a singleton and reuses the helper instance, subsequent navigations to the view would result in a broken hashtag strip (neutral colors, no menu on click). This change modifies `HashtagStripHelper.destroy()` to preserve these configuration callbacks while still cleaning up DOM references and observers. Also updated `tests/ui/video-list-view-popular-tags.test.mjs` to: 1. Mock `IntersectionObserver` and `ResizeObserver` on `global` to support `VideoCard` in the test environment. 2. Skip the trimming test case, as `VideoListView` now enforces `scrollable: true`. Fixes the issue where the hashtag pill menu fails to open and pills do not show interest colors after navigation. --- js/ui/components/hashtagStripHelper.js | 2 - .../ui/video-list-view-popular-tags.test.mjs | 114 ++++-------------- 2 files changed, 24 insertions(+), 92 deletions(-) diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index 6dc2dde5..b141e414 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -216,8 +216,6 @@ export class HashtagStripHelper { this.container = null; this._tagStrip = null; this._sortedTags = []; - this.tagStateResolver = null; - this.activateHandler = null; } _resolveTagState(tag) { diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs index 15c8d017..2553f9ad 100644 --- a/tests/ui/video-list-view-popular-tags.test.mjs +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -11,6 +11,27 @@ function createViewDom() { const { document } = dom.window; const listRoot = document.getElementById("videoList"); const tagsRoot = document.getElementById("recentVideoTags"); + + // Mock IntersectionObserver for VideoCard (both on window and global for module access) + const MockIntersectionObserver = class IntersectionObserver { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} + }; + + dom.window.IntersectionObserver = MockIntersectionObserver; + global.IntersectionObserver = MockIntersectionObserver; + + const MockResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} + }; + dom.window.ResizeObserver = MockResizeObserver; + global.ResizeObserver = MockResizeObserver; + return { dom, document, listRoot, tagsRoot }; } @@ -59,96 +80,9 @@ test("VideoListView renders sorted popular tag pills", () => { ); }); -test("VideoListView trims popular tags to available width", () => { - const { dom, document, listRoot, tagsRoot } = createViewDom(); - const view = new VideoListView({ document }); - view.mount(listRoot); - view.setPopularTagsContainer(tagsRoot); - - const prototype = dom.window.HTMLElement.prototype; - const originalScrollWidth = Object.getOwnPropertyDescriptor( - prototype, - "scrollWidth", - ); - - Object.defineProperty(tagsRoot, "clientWidth", { - configurable: true, - value: 160, - }); - - Object.defineProperty(prototype, "scrollWidth", { - configurable: true, - get() { - if (this.classList && this.classList.contains("video-tag-strip")) { - return this.childElementCount * 80; - } - - if (originalScrollWidth && typeof originalScrollWidth.get === "function") { - return originalScrollWidth.get.call(this); - } - - return 0; - }, - }); - - try { - const videos = [ - { - id: "v1", - title: "First", - pubkey: "pub1", - created_at: 3, - nip71: { hashtags: ["Nostr", "Video"] }, - }, - { - id: "v2", - title: "Second", - pubkey: "pub2", - created_at: 2, - tags: [ - ["t", "nostr"], - ["t", "Learning"], - ], - }, - { - id: "v3", - title: "Third", - pubkey: "pub3", - created_at: 1, - nip71: { hashtags: ["video"] }, - }, - ]; - - view.render(videos); - - let buttons = tagsRoot.querySelectorAll("button"); - assert.equal(buttons.length, 2); - assert.deepEqual( - Array.from(buttons, (button) => button.dataset.tag), - ["Nostr", "Video"], - ); - - Object.defineProperty(tagsRoot, "clientWidth", { - configurable: true, - value: 400, - }); - - view.renderPopularTagStrip(); - - buttons = tagsRoot.querySelectorAll("button"); - assert.equal(buttons.length, 3); - assert.deepEqual( - Array.from(buttons, (button) => button.dataset.tag), - ["Nostr", "Video", "Learning"], - ); - } finally { - if (originalScrollWidth) { - Object.defineProperty(prototype, "scrollWidth", originalScrollWidth); - } else { - delete prototype.scrollWidth; - } - } -}); +// test("VideoListView trims popular tags to available width", () => { + // Test skipped: VideoListView now uses scrollable: true, so trimming is disabled. +// }); test("VideoListView applies tag preference variants to popular tags", () => { const { document, listRoot, tagsRoot } = createViewDom(); From 3ded1b5d2e288a8d9481731c93c24d9ac9aaa0d4 Mon Sep 17 00:00:00 2001 From: bitvid-agent Date: Fri, 23 Jan 2026 01:47:20 +0000 Subject: [PATCH 10/96] fix(ai): formatting (agent) --- css/tailwind.source.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 92b9f1b1..f66bd4b4 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2433,13 +2433,12 @@ } .video-tag-strip--scroll::-webkit-scrollbar-track { - background: - linear-gradient( - to bottom, - transparent calc(50% - 0.5px), - var(--scrollbar-track) calc(50% - 0.5px) calc(50% + 0.5px), - transparent calc(50% + 0.5px) - ); + background: linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + var(--scrollbar-track) calc(50% - 0.5px) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); } .video-tag-strip--scroll::-webkit-scrollbar-thumb { From 629acb16d24f3949523d0013afb7384c19140a5b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:56:51 +0000 Subject: [PATCH 11/96] Remove scrollbar from video tag strip and add bounce animation Hidden scrollbars for `.video-tag-strip--scroll` in CSS to improve aesthetics. Implemented a "peek" (bounce) animation in `HashtagStripHelper` that triggers when the tag list is rendered and overflows the container, indicating to the user that the list is scrollable. Added unit tests for the new logic. --- css/tailwind.source.css | 23 +-- js/ui/components/hashtagStripHelper.js | 40 +++++ tests/ui/hashtag-strip-helper-bounce.test.mjs | 144 ++++++++++++++++++ 3 files changed, 189 insertions(+), 18 deletions(-) create mode 100644 tests/ui/hashtag-strip-helper-bounce.test.mjs diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 92b9f1b1..b2a0bece 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2424,27 +2424,14 @@ overflow-y: hidden; -webkit-overflow-scrolling: touch; padding-bottom: var(--space-xs); - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) transparent; + scrollbar-width: none; + -ms-overflow-style: none; } .video-tag-strip--scroll::-webkit-scrollbar { - height: 4px; - } - - .video-tag-strip--scroll::-webkit-scrollbar-track { - background: - linear-gradient( - to bottom, - transparent calc(50% - 0.5px), - var(--scrollbar-track) calc(50% - 0.5px) calc(50% + 0.5px), - transparent calc(50% + 0.5px) - ); - } - - .video-tag-strip--scroll::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: var(--radius-pill); + display: none; + width: 0; + height: 0; } .video-tag-pill { diff --git a/js/ui/components/hashtagStripHelper.js b/js/ui/components/hashtagStripHelper.js index b141e414..beb90108 100644 --- a/js/ui/components/hashtagStripHelper.js +++ b/js/ui/components/hashtagStripHelper.js @@ -200,6 +200,8 @@ export class HashtagStripHelper { this._tagStrip = strip; if (!this.scrollable) { trimTagPillStripToFit({ strip, container: root }); + } else { + this._triggerScrollHint(); } root.hidden = strip.childElementCount === 0; } finally { @@ -218,6 +220,44 @@ export class HashtagStripHelper { this._sortedTags = []; } + _triggerScrollHint() { + const strip = this._tagStrip; + if (!strip) { + return; + } + + if ( + this.window && + typeof this.window.requestAnimationFrame === "function" && + typeof strip.scrollTo === "function" + ) { + this.window.requestAnimationFrame(() => { + if (!strip.isConnected) { + return; + } + + if (strip.scrollWidth > strip.clientWidth) { + if (this.window && typeof this.window.setTimeout === "function") { + this.window.setTimeout(() => { + if (!strip.isConnected || strip.scrollLeft !== 0) { + return; + } + + strip.scrollTo({ left: 40, behavior: "smooth" }); + + this.window.setTimeout(() => { + if (!strip.isConnected) { + return; + } + strip.scrollTo({ left: 0, behavior: "smooth" }); + }, 800); + }, 600); + } + } + }); + } + } + _resolveTagState(tag) { const resolver = this.tagStateResolver; if (typeof resolver !== "function") { diff --git a/tests/ui/hashtag-strip-helper-bounce.test.mjs b/tests/ui/hashtag-strip-helper-bounce.test.mjs new file mode 100644 index 00000000..50d682c1 --- /dev/null +++ b/tests/ui/hashtag-strip-helper-bounce.test.mjs @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { HashtagStripHelper } from "../../js/ui/components/hashtagStripHelper.js"; + +function createDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + }); + + // Mock requestAnimationFrame to capture callback + let rafCallback = null; + dom.window.requestAnimationFrame = (cb) => { + rafCallback = cb; + return 1; + }; + + // Helper to trigger pending RAF + dom.window.triggerRaf = () => { + if (rafCallback) { + const cb = rafCallback; + rafCallback = null; + cb(); + return true; + } + return false; + }; + + // Mock createElement to ensure scrollTo exists on created elements + // This is required because JSDOM might not implement scrollTo on div elements + // and _triggerScrollHint checks for it. + const originalCreateElement = dom.window.document.createElement; + dom.window.document.createElement = function(tagName, options) { + const el = originalCreateElement.call(dom.window.document, tagName, options); + if (typeof el.scrollTo !== 'function') { + el.scrollTo = () => {}; + } + return el; + }; + + return { dom, window: dom.window, document: dom.window.document }; +} + +test("HashtagStripHelper triggers scroll hint when content overflows", (t) => { + const { window, document } = createDom(); + const container = document.getElementById("container"); + + // Mock setTimeout + const timeouts = []; + window.setTimeout = (cb, delay) => { + timeouts.push({ cb, delay }); + return timeouts.length; + }; + + const helper = new HashtagStripHelper({ + window, + document, + scrollable: true, + }); + + helper.mount(container); + + helper._sortedTags = ["Tag1", "Tag2", "Tag3"]; + + // Render creates the element and requests RAF + helper.render(); + + const strip = container.querySelector(".video-tag-strip"); + assert(strip, "Strip element should be rendered"); + + // Mock scroll properties on the newly created element + Object.defineProperty(strip, "scrollWidth", { value: 200, configurable: true }); + Object.defineProperty(strip, "clientWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "scrollLeft", { value: 0, writable: true, configurable: true }); + Object.defineProperty(strip, "isConnected", { value: true, configurable: true }); + + // Mock scrollTo to spy on calls + let scrollToCalls = []; + strip.scrollTo = (options) => { + scrollToCalls.push(options); + }; + + // Trigger the RAF callback which was scheduled during render() + const triggered = window.triggerRaf(); + assert(triggered, "Should have scheduled a RAF callback"); + + assert.equal(timeouts.length, 1, "Should schedule initial timeout"); + + // Execute first timeout + const firstTimeout = timeouts.shift(); + assert.equal(firstTimeout.delay, 600, "First timeout should be 600ms"); + firstTimeout.cb(); // Execute + + assert.equal(scrollToCalls.length, 1, "Should have called scrollTo once"); + assert.equal(scrollToCalls[0].left, 40, "Should scroll to 40px"); + + assert.equal(timeouts.length, 1, "Should schedule second timeout"); + const secondTimeout = timeouts.shift(); + assert.equal(secondTimeout.delay, 800, "Second timeout should be 800ms"); + + // Execute second timeout + secondTimeout.cb(); + + assert.equal(scrollToCalls.length, 2, "Should have called scrollTo twice"); + assert.equal(scrollToCalls[1].left, 0, "Should scroll back to 0px"); +}); + +test("HashtagStripHelper does not trigger scroll hint when no overflow", (t) => { + const { window, document } = createDom(); + const container = document.getElementById("container"); + + const timeouts = []; + window.setTimeout = (cb, delay) => { + timeouts.push({ cb, delay }); + return timeouts.length; + }; + + const helper = new HashtagStripHelper({ + window, + document, + scrollable: true, + }); + + helper.mount(container); + helper._sortedTags = ["Tag1"]; + helper.render(); + + const strip = container.querySelector(".video-tag-strip"); + + // Mock properties + Object.defineProperty(strip, "scrollWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "clientWidth", { value: 100, configurable: true }); + Object.defineProperty(strip, "isConnected", { value: true, configurable: true }); + + strip.scrollTo = () => { + throw new Error("Should not scroll"); + }; + + // Trigger RAF + const triggered = window.triggerRaf(); + assert(triggered, "Should have scheduled a RAF callback"); + + assert.equal(timeouts.length, 0, "Should not schedule timeout"); +}); From 6700c5b76d18bc0071c77972451d7dfb888d3873 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 02:06:38 +0000 Subject: [PATCH 12/96] Redesign watch history cards and fix timestamp display bug - Redesigned watch history cards to use a list view layout (flex-row) for better space utilization on both desktop and mobile. - Updated `buildHistoryCard` in `js/historyView.js` to support a `variant` prop ('compact' or 'modal') for context-specific sizing. - Updated `js/ui/profileModalController.js` to pass `variant: "modal"` to the watch history renderer. - Fixed a bug where the "Watched [time] ago" timestamp was unavailable because `normalizeHistoryItems` failed to check `pointer.watchedAt`. - Verified changes with a reproduction script for the timestamp bug and existing unit tests for the controller. --- js/historyView.js | 309 +++++++++++++++++--------------- js/ui/profileModalController.js | 1 + 2 files changed, 166 insertions(+), 144 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 22392d73..22675858 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -232,7 +232,9 @@ function normalizeHistoryItems(rawItems) { ? candidate.watchedAt : Number.isFinite(candidate?.timestamp) ? candidate.timestamp - : null; + : Number.isFinite(pointer?.watchedAt) + ? pointer.watchedAt + : null; const watchedAt = watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0; normalized.push({ @@ -1117,17 +1119,34 @@ function refreshRegisteredHistoryCards(video, context, pointerKeys = null) { }); } -export function buildHistoryCard({ item, video, profile }) { +export function buildHistoryCard({ item, video, profile, variant }) { + const isCompact = variant === "compact" || variant === "modal"; + const article = document.createElement("article"); - article.className = "watch-history-card"; - article.dataset.pointerKey = item.pointerKey; - article.dataset.historyCard = "true"; - if (video?.isPrivate) { - article.classList.add("watch-history-card--private"); + article.className = + "watch-history-card group relative flex flex-row items-start gap-4 p-3 sm:p-4 rounded-lg transition-colors"; + if (!isCompact) { + article.classList.add("hover:bg-surface-panel-hover", "hover:shadow-sm"); + } else { + article.classList.add("hover:bg-surface-highlight"); } - const primary = document.createElement("div"); - primary.className = "watch-history-card__primary"; + if (video?.isPrivate) { + article.classList.add( + "border", + "border-status-info", + "border-opacity-40", + "bg-surface-alt" + ); + } + + article.dataset.pointerKey = item.pointerKey; + article.dataset.historyCard = "true"; + + const pointerVideoId = getPointerVideoId(video, item.pointer); + if (pointerVideoId) { + article.dataset.videoId = pointerVideoId; + } const playbackData = (() => { if (!video) { @@ -1143,10 +1162,10 @@ export function buildHistoryCard({ item, video, profile }) { return { url, magnet: magnetRaw }; })(); - const pointerVideoId = getPointerVideoId(video, item.pointer); - + // --- Thumbnail Column --- const thumbnailLink = document.createElement("a"); - thumbnailLink.className = "watch-history-card__thumbnail"; + const thumbnailSizeClass = isCompact ? "w-28 sm:w-32" : "w-32 sm:w-48"; + thumbnailLink.className = `relative block flex-shrink-0 aspect-video rounded-md overflow-hidden bg-surface-alt hover:opacity-90 transition-opacity ${thumbnailSizeClass}`; thumbnailLink.href = "#"; thumbnailLink.dataset.historyAction = "play"; thumbnailLink.dataset.pointerKey = item.pointerKey; @@ -1160,9 +1179,8 @@ export function buildHistoryCard({ item, video, profile }) { thumbnailLink.dataset.playMagnet = playbackData.magnet; } - const thumbnailInner = document.createElement("div"); - thumbnailInner.className = "watch-history-card__thumbnailInner"; const thumbnailImg = document.createElement("img"); + thumbnailImg.className = "w-full h-full object-cover"; thumbnailImg.alt = video?.title || "Video thumbnail"; const thumbnailSrc = video && typeof video.thumbnail === "string" && video.thumbnail.trim() @@ -1171,14 +1189,19 @@ export function buildHistoryCard({ item, video, profile }) { thumbnailImg.src = thumbnailSrc; thumbnailImg.loading = "lazy"; thumbnailImg.decoding = "async"; - thumbnailInner.appendChild(thumbnailImg); - thumbnailLink.appendChild(thumbnailInner); + thumbnailLink.appendChild(thumbnailImg); - const details = document.createElement("div"); - details.className = "watch-history-card__details"; + // --- Content Column --- + const content = document.createElement("div"); + content.className = "flex flex-col flex-1 min-w-0 gap-1.5"; + + // Title Row + const header = document.createElement("div"); + header.className = "flex justify-between items-start gap-2"; const titleLink = document.createElement("a"); - titleLink.className = "watch-history-card__title"; + titleLink.className = + "text-sm font-semibold text-text line-clamp-2 leading-snug hover:text-accent transition-colors"; titleLink.href = "#"; titleLink.dataset.historyAction = "play"; titleLink.dataset.pointerKey = item.pointerKey; @@ -1193,164 +1216,160 @@ export function buildHistoryCard({ item, video, profile }) { } titleLink.textContent = video?.title || "Untitled video"; - const created = document.createElement("p"); - created.className = "watch-history-card__created"; - const createdAt = Number.isFinite(video?.created_at) - ? video.created_at - : null; - created.textContent = createdAt - ? `Published ${formatRelativeTime(createdAt)}` - : "Published date unavailable"; + header.appendChild(titleLink); + content.appendChild(header); - details.appendChild(titleLink); - details.appendChild(created); - const moderationMount = document.createElement("div"); - moderationMount.className = "watch-history-card__moderation flex flex-wrap items-center gap-sm"; - details.appendChild(moderationMount); + // Author Row + const authorRow = document.createElement("div"); + authorRow.className = "flex items-center gap-2"; - primary.appendChild(thumbnailLink); - primary.appendChild(details); - - const meta = document.createElement("div"); - meta.className = "watch-history-card__meta"; - - const watched = document.createElement("p"); - watched.className = "watch-history-card__watched"; - watched.textContent = item.watchedAt - ? `Watched ${formatRelativeTime(item.watchedAt)}` - : "Watched time unavailable"; - meta.appendChild(watched); - - const creator = document.createElement("div"); - creator.className = "watch-history-card__creator"; - - const creatorAvatarButton = document.createElement("button"); - creatorAvatarButton.type = "button"; - creatorAvatarButton.className = "watch-history-card__creatorAvatar"; - creatorAvatarButton.dataset.historyAction = "channel"; - creatorAvatarButton.dataset.pointerKey = item.pointerKey; + const avatarBtn = document.createElement("button"); + avatarBtn.type = "button"; + avatarBtn.className = + "flex-shrink-0 w-5 h-5 rounded-full overflow-hidden bg-surface-alt hover:opacity-80 transition-opacity"; + avatarBtn.dataset.historyAction = "channel"; + avatarBtn.dataset.pointerKey = item.pointerKey; if (video?.pubkey) { - creatorAvatarButton.dataset.author = video.pubkey; + avatarBtn.dataset.author = video.pubkey; } + const avatarImg = document.createElement("img"); + avatarImg.className = "w-full h-full object-cover"; const avatarSrc = profile && typeof profile.picture === "string" && profile.picture.trim() ? profile.picture.trim() : FALLBACK_AVATAR; avatarImg.src = avatarSrc; - avatarImg.alt = profile?.name || profile?.display_name || "Creator avatar"; - creatorAvatarButton.appendChild(avatarImg); + avatarImg.alt = profile?.name || "Avatar"; + avatarBtn.appendChild(avatarImg); - const creatorNameButton = document.createElement("button"); - creatorNameButton.type = "button"; - creatorNameButton.className = "watch-history-card__creatorName"; - creatorNameButton.dataset.historyAction = "channel"; - creatorNameButton.dataset.pointerKey = item.pointerKey; + const authorNameBtn = document.createElement("button"); + authorNameBtn.type = "button"; + authorNameBtn.className = + "text-xs font-medium text-muted hover:text-text transition-colors truncate"; + authorNameBtn.dataset.historyAction = "channel"; + authorNameBtn.dataset.pointerKey = item.pointerKey; if (video?.pubkey) { - creatorNameButton.dataset.author = video.pubkey; + authorNameBtn.dataset.author = video.pubkey; } + const app = getAppInstance(); let creatorLabel = profile?.display_name || profile?.name || profile?.username || "Unknown"; if ((!creatorLabel || creatorLabel === "Unknown") && video?.pubkey) { const encoded = app?.safeEncodeNpub?.(video.pubkey) || ""; - creatorLabel = formatShortNpub(encoded) || encoded || video.pubkey.slice(0, 8).concat("…"); + creatorLabel = + formatShortNpub(encoded) || encoded || video.pubkey.slice(0, 8).concat("…"); } - creatorNameButton.textContent = creatorLabel; + authorNameBtn.textContent = creatorLabel; - creator.appendChild(creatorAvatarButton); - creator.appendChild(creatorNameButton); - meta.appendChild(creator); + authorRow.appendChild(avatarBtn); + authorRow.appendChild(authorNameBtn); + content.appendChild(authorRow); - const actions = document.createElement("div"); - actions.className = "watch-history-card__actions"; + // Moderation Badge Mount + const moderationMount = document.createElement("div"); + moderationMount.className = "flex flex-wrap items-center gap-2 mt-0.5"; + content.appendChild(moderationMount); - const playButton = document.createElement("button"); - playButton.type = "button"; - playButton.className = - "watch-history-card__action watch-history-card__action--primary"; - playButton.dataset.historyAction = "play"; - playButton.dataset.pointerKey = item.pointerKey; - if (pointerVideoId) { - playButton.dataset.videoId = pointerVideoId; - } - if (playbackData.url) { - playButton.dataset.playUrl = encodeURIComponent(playbackData.url); - } - if (playbackData.magnet) { - playButton.dataset.playMagnet = playbackData.magnet; - } - playButton.textContent = "Play"; + // Meta Row (Timestamp) + const metaRow = document.createElement("div"); + metaRow.className = + "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-strong mt-auto pt-1"; - const channelButton = document.createElement("button"); - channelButton.type = "button"; - channelButton.className = "watch-history-card__action"; - channelButton.dataset.historyAction = "channel"; - channelButton.dataset.pointerKey = item.pointerKey; - if (video?.pubkey) { - channelButton.dataset.author = video.pubkey; + if (item.watchedAt) { + const watchedSpan = document.createElement("span"); + watchedSpan.textContent = formatRelativeTime(item.watchedAt); + watchedSpan.title = new Date(item.watchedAt * 1000).toLocaleString(); + metaRow.appendChild(watchedSpan); + } else { + const unknownSpan = document.createElement("span"); + unknownSpan.textContent = "Time unavailable"; + metaRow.appendChild(unknownSpan); } - channelButton.textContent = "Open channel"; - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = - "watch-history-card__action watch-history-card__action--danger"; - removeButton.dataset.variant = "danger"; - removeButton.dataset.historyAction = "remove"; - removeButton.dataset.pointerKey = item.pointerKey; - removeButton.textContent = "Remove from history"; - if (item.pointer?.type) { - removeButton.dataset.pointerType = item.pointer.type; - } - if (item.pointer?.value) { - removeButton.dataset.pointerValue = item.pointer.value; - } - if (item.pointer?.relay) { - removeButton.dataset.pointerRelay = item.pointer.relay; - } - if (Number.isFinite(item.watchedAt)) { - removeButton.dataset.pointerWatchedAt = String(item.watchedAt); - } - if (item.pointer?.session === true) { - removeButton.dataset.pointerSession = "true"; - } - removeButton.setAttribute( - "aria-label", - "Remove this history entry (sync may take a moment)." - ); - removeButton.title = - "Removes this entry from history. Relay sync may take a moment."; + if (video?.created_at) { + const sep = document.createElement("span"); + sep.className = "text-muted/40"; + sep.textContent = "•"; + metaRow.appendChild(sep); - actions.appendChild(playButton); - actions.appendChild(channelButton); - actions.appendChild(removeButton); - - meta.appendChild(actions); - - article.appendChild(primary); - article.appendChild(meta); - - if (pointerVideoId) { - article.dataset.videoId = pointerVideoId; + const pubSpan = document.createElement("span"); + pubSpan.textContent = `Published ${formatRelativeTime(video.created_at)}`; + metaRow.appendChild(pubSpan); } + content.appendChild(metaRow); + + // Actions Row + const actionsRow = document.createElement("div"); + actionsRow.className = + "flex flex-wrap items-center gap-4 mt-2 pt-2 border-t border-border/40"; + + const createActionBtn = (label, action, btnVariant = "default") => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `text-xs font-semibold ${ + btnVariant === "danger" + ? "text-status-danger hover:text-status-danger-strong" + : "text-muted hover:text-text" + } transition-colors`; + btn.textContent = label; + btn.dataset.historyAction = action; + btn.dataset.pointerKey = item.pointerKey; + + if (action === "play") { + if (pointerVideoId) btn.dataset.videoId = pointerVideoId; + if (playbackData.url) + btn.dataset.playUrl = encodeURIComponent(playbackData.url); + if (playbackData.magnet) + btn.dataset.playMagnet = playbackData.magnet; + } else if (action === "channel" && video?.pubkey) { + btn.dataset.author = video.pubkey; + } else if (action === "remove") { + if (item.pointer?.type) btn.dataset.pointerType = item.pointer.type; + if (item.pointer?.value) btn.dataset.pointerValue = item.pointer.value; + if (item.pointer?.relay) btn.dataset.pointerRelay = item.pointer.relay; + if (Number.isFinite(item.watchedAt)) + btn.dataset.pointerWatchedAt = String(item.watchedAt); + if (item.pointer?.session === true) + btn.dataset.pointerSession = "true"; + btn.title = "Remove from history"; + } + return btn; + }; + + const playBtn = createActionBtn("Play now", "play", "default"); + playBtn.classList.add("text-accent", "hover:text-accent-strong"); + + const channelBtn = createActionBtn("Channel", "channel"); + const removeBtn = createActionBtn("Remove", "remove", "danger"); + + actionsRow.appendChild(playBtn); + actionsRow.appendChild(channelBtn); + actionsRow.appendChild(removeBtn); + + content.appendChild(actionsRow); + + article.appendChild(thumbnailLink); + article.appendChild(content); + const moderationContext = normalizeVideoModerationContext(video?.moderation); const cardRef = { pointerKey: item.pointerKey, article, - primary, - meta, - details, + primary: thumbnailLink, + meta: content, + details: content, badgeMount: moderationMount, - thumbnailInner, + thumbnailInner: null, thumbnailLink, titleLink, - playButton, - channelButton, - removeButton, - creatorNameButton, - avatarButton: creatorAvatarButton, + playButton: playBtn, + channelButton: channelBtn, + removeButton: removeBtn, + creatorNameButton: authorNameBtn, + avatarButton: avatarBtn, video: video || null, hiddenContainer: null, badgeEl: null, @@ -1420,7 +1439,8 @@ export function createWatchHistoryRenderer(config = {}) { return controller.handleWatchHistoryRemoval(payload); } return defaultRemoveHandler(payload); - } + }, + variant = "default" } = config; const syncEnabled = @@ -1979,7 +1999,8 @@ export function createWatchHistoryRenderer(config = {}) { const card = buildHistoryCard({ item: entry, video: entry.metadata?.video || null, - profile: entry.metadata?.profile || null + profile: entry.metadata?.profile || null, + variant }); container.list.appendChild(card); }); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 25a34940..b93a26cc 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1892,6 +1892,7 @@ export class ProfileModalController { featureBannerSelector: "#profileHistoryFeatureBanner", sessionWarningSelector: "#profileHistorySessionWarning", emptyCopy: "You haven’t watched any videos yet.", + variant: "modal", remove: (payload) => this.callbacks.onHistoryReady({ ...(typeof payload === "object" && payload ? payload : {}), From 7221ef0eaa7fde18580e3f0a55e493068626f29b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 02:20:13 +0000 Subject: [PATCH 13/96] Add hashtag helper bar to Explore tab and refactor ID - Add `#videoListTags` container to `views/explore.html` to support the hashtag helper bar in the Explore view. - Rename `#recentVideoTags` to `#videoListTags` in `views/most-recent-videos.html` for consistency across views. - Update `js/ui/videoListViewController.js` and `js/app.js` to reference the new generic `#videoListTags` ID. - Update `tests/ui/video-list-view-popular-tags.test.mjs` to reflect the ID change. This enables the hashtag aggregation and interaction features (filtering, muting, following) on the Explore tab, reusing the existing `VideoListView` logic. --- js/app.js | 4 ++-- js/ui/videoListViewController.js | 4 ++-- tests/ui/video-list-view-popular-tags.test.mjs | 4 ++-- views/explore.html | 1 + views/most-recent-videos.html | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/js/app.js b/js/app.js index 8e946067..5c30bf6f 100644 --- a/js/app.js +++ b/js/app.js @@ -2821,7 +2821,7 @@ class Application { let tagsRoot = null; if (includeTags) { - tagsRoot = document.getElementById("recentVideoTags"); + tagsRoot = document.getElementById("videoListTags"); } this.videoListPopularTags = tagsRoot || null; if (typeof this.videoListView.setPopularTagsContainer === "function") { @@ -2870,7 +2870,7 @@ class Application { : "Refreshing videos…"; this.videoList = isElement(container) ? container : null; - const tagsRoot = document.getElementById("recentVideoTags"); + const tagsRoot = document.getElementById("videoListTags"); this.videoListPopularTags = isElement(tagsRoot) ? tagsRoot : null; if (typeof this.videoListView.setPopularTagsContainer === "function") { this.videoListView.setPopularTagsContainer(this.videoListPopularTags); diff --git a/js/ui/videoListViewController.js b/js/ui/videoListViewController.js index 63999d89..46ae2f44 100644 --- a/js/ui/videoListViewController.js +++ b/js/ui/videoListViewController.js @@ -33,7 +33,7 @@ export default class VideoListViewController { if (this.isElement(popularTagsContainer)) { popularTags = popularTagsContainer; } else if (includeTags) { - popularTags = this.getElementById("recentVideoTags"); + popularTags = this.getElementById("videoListTags"); } if (typeof view.setPopularTagsContainer === "function") { @@ -87,7 +87,7 @@ export default class VideoListViewController { if (this.isElement(popularTagsContainer)) { tagsRoot = popularTagsContainer; } else { - tagsRoot = this.getElementById("recentVideoTags"); + tagsRoot = this.getElementById("videoListTags"); } if (typeof view.setPopularTagsContainer === "function") { view.setPopularTagsContainer(this.isElement(tagsRoot) ? tagsRoot : null); diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs index 2553f9ad..5d07834e 100644 --- a/tests/ui/video-list-view-popular-tags.test.mjs +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -6,11 +6,11 @@ import { VideoListView } from "../../js/ui/views/VideoListView.js"; function createViewDom() { const dom = new JSDOM( - "
" + "
" ); const { document } = dom.window; const listRoot = document.getElementById("videoList"); - const tagsRoot = document.getElementById("recentVideoTags"); + const tagsRoot = document.getElementById("videoListTags"); // Mock IntersectionObserver for VideoCard (both on window and global for module access) const MockIntersectionObserver = class IntersectionObserver { diff --git a/views/explore.html b/views/explore.html index f357ed73..63cfe572 100644 --- a/views/explore.html +++ b/views/explore.html @@ -25,6 +25,7 @@ +