From 876020b90fa8e6237d7e7d689ca3c2248bdeb4c7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:13:05 -0400 Subject: [PATCH] Add popular tag strip support to recent videos --- js/app.js | 21 ++++ js/subscriptions.js | 3 + js/ui/views/VideoListView.js | 97 +++++++++++++++++++ .../ui/video-list-view-popular-tags.test.mjs | 80 +++++++++++++++ views/most-recent-videos.html | 1 + 5 files changed, 202 insertions(+) create mode 100644 tests/ui/video-list-view-popular-tags.test.mjs diff --git a/js/app.js b/js/app.js index e8b6c892..5e44115c 100644 --- a/js/app.js +++ b/js/app.js @@ -887,6 +887,7 @@ class Application { this.currentVideoPointerKey = null; this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videoList = null; + this.videoListPopularTags = null; this.modalViewCountUnsub = null; this.modalReactionUnsub = null; this.modalReactionPointerKey = null; @@ -1197,6 +1198,11 @@ class Application { } this.videoList = null; + if (this.videoListPopularTags) { + this.videoListPopularTags.textContent = ""; + this.videoListPopularTags.hidden = true; + } + this.videoListPopularTags = null; if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") { this.mediaLoader.disconnect(); @@ -2902,6 +2908,11 @@ class Application { const target = container || document.getElementById("videoList"); this.videoList = target || null; + const tagsRoot = document.getElementById("recentVideoTags"); + this.videoListPopularTags = tagsRoot || null; + if (typeof this.videoListView.setPopularTagsContainer === "function") { + this.videoListView.setPopularTagsContainer(this.videoListPopularTags); + } this.videoListView.mount(this.videoList || null); return this.videoList; } @@ -2933,6 +2944,11 @@ class Application { : "Refreshing videos…"; this.videoList = isElement(container) ? container : null; + const tagsRoot = document.getElementById("recentVideoTags"); + this.videoListPopularTags = isElement(tagsRoot) ? tagsRoot : null; + if (typeof this.videoListView.setPopularTagsContainer === "function") { + this.videoListView.setPopularTagsContainer(this.videoListPopularTags); + } if (this.videoList) { this.videoList.innerHTML = getSidebarLoadingMarkup(messageContext); @@ -8831,6 +8847,11 @@ class Application { } this.videoList = null; + if (this.videoListPopularTags) { + this.videoListPopularTags.textContent = ""; + this.videoListPopularTags.hidden = true; + } + this.videoListPopularTags = null; } shareActiveVideo() { diff --git a/js/subscriptions.js b/js/subscriptions.js index 487d97c4..0ccbff18 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1123,6 +1123,9 @@ class SubscriptionsManager { }; const listView = new VideoListView(listViewConfig); + if (typeof listView.setPopularTagsContainer === "function") { + listView.setPopularTagsContainer(null); + } listView.setPlaybackHandler((detail) => { if (!detail) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index e4085948..7202dc57 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -1,10 +1,12 @@ import { VideoCard } from "../components/VideoCard.js"; +import { renderTagPillStrip } from "../components/tagPillList.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__"; @@ -174,6 +176,8 @@ export class VideoListView { this.currentVideos = []; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; + this.popularTagsRoot = null; + this._popularTagStrip = null; this.handlers = { playback: null, @@ -256,6 +260,12 @@ export class VideoListView { this.currentVideos = []; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; + if (this.popularTagsRoot) { + this.popularTagsRoot.textContent = ""; + this.popularTagsRoot.hidden = true; + } + this.popularTagsRoot = null; + this._popularTagStrip = null; } setPlaybackHandler(handler) { @@ -282,6 +292,91 @@ export class VideoListView { this.handlers.moderationOverride = typeof handler === "function" ? handler : null; } + setPopularTagsContainer(container) { + 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; + } + } + + updatePopularTags(videos) { + const root = this.popularTagsRoot; + if (!root) { + return; + } + + if (!Array.isArray(videos) || videos.length === 0) { + root.textContent = ""; + root.hidden = true; + this._popularTagStrip = null; + 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) { + root.textContent = ""; + root.hidden = true; + this._popularTagStrip = null; + 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); + }); + + const tags = tagEntries.map((entry) => entry.tag); + const doc = this.document || root.ownerDocument || null; + if (!doc) { + root.textContent = ""; + root.hidden = true; + this._popularTagStrip = null; + return; + } + + const { root: strip } = renderTagPillStrip({ document: doc, tags }); + root.textContent = ""; + root.appendChild(strip); + root.hidden = false; + this._popularTagStrip = strip; + } + render(videos, metadata = null) { if (!this.container) { return []; @@ -307,6 +402,8 @@ export class VideoListView { return canEdit || !isPrivate; }); + this.updatePopularTags(displayVideos); + this.syncViewCountSubscriptions(displayVideos); this.cleanupThumbnailCache(displayVideos); this.cleanupHealthCache(displayVideos); diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs new file mode 100644 index 00000000..755d1676 --- /dev/null +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import { VideoListView } from "../../js/ui/views/VideoListView.js"; + +function createViewDom() { + const dom = new JSDOM( + "