From 33cc5d23c70672b2816c4f434fbc2f6622506409 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 23 Jan 2026 02:09:08 -0500 Subject: [PATCH] Add kids feed view --- components/sidebar.html | 9 +++ js/app.js | 122 ++++++++++++++++++++++++++++++++++++++++ js/hashChangeHandler.js | 4 ++ js/kidsView.js | 119 +++++++++++++++++++++++++++++++++++++++ js/viewManager.js | 15 +++++ views/kids.html | 40 +++++++++++++ 6 files changed, 309 insertions(+) create mode 100644 js/kidsView.js create mode 100644 views/kids.html diff --git a/components/sidebar.html b/components/sidebar.html index b0e41389..8a91351e 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -54,6 +54,15 @@ /> For You + + + For Kids + this.isAuthorBlocked(pubkey), + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, + }; + } + refreshForYouFeed({ reason, fallbackVideos } = {}) { const runtime = this.buildForYouFeedRuntime(); const normalizedReason = typeof reason === "string" ? reason : undefined; @@ -6148,6 +6165,63 @@ class Application { }); } + refreshKidsFeed({ reason, fallbackVideos } = {}) { + const runtime = this.buildKidsFeedRuntime(); + const normalizedReason = typeof reason === "string" ? reason : undefined; + const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; + + if (!this.feedEngine || typeof this.feedEngine.run !== "function") { + const metadata = { + reason: normalizedReason, + engine: "unavailable", + }; + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + this.renderVideoList({ videos: fallback, metadata }); + return Promise.resolve({ videos: fallback, metadata }); + } + + return this.feedEngine + .run("kids", { runtime }) + .then((result) => { + const videos = Array.isArray(result?.videos) ? result.videos : []; + const metadata = { + ...(result?.metadata || {}), + }; + if (normalizedReason) { + metadata.reason = normalizedReason; + } + + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + + const payload = { videos, metadata }; + this.renderVideoList(payload); + return payload; + }) + .catch((error) => { + devLogger.error("[Application] Failed to run kids feed:", error); + const metadata = { + reason: normalizedReason || "error:kids-feed", + error: true, + }; + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + const payload = { videos: fallback, metadata }; + this.renderVideoList(payload); + return payload; + }); + } + refreshExploreFeed({ reason, fallbackVideos } = {}) { const runtime = this.buildExploreFeedRuntime(); const normalizedReason = typeof reason === "string" ? reason : undefined; @@ -6367,6 +6441,54 @@ class Application { } } + async loadKidsVideos(forceFetch = false) { + devLogger.log("Starting loadKidsVideos... (forceFetch =", forceFetch, ")"); + this.setFeedTelemetryContext("kids"); + + const container = this.mountVideoListView({ includeTags: false }); + const hasCachedVideos = + this.nostrService && + Array.isArray(this.nostrService.getFilteredActiveVideos()) && + this.nostrService.getFilteredActiveVideos().length > 0; + + if (!hasCachedVideos) { + if (this.videoListView && container) { + this.videoListView.showLoading("Fetching kids videos…"); + } else if (container) { + container.innerHTML = getSidebarLoadingMarkup("Fetching kids videos…"); + } + } + + let initialRefreshPromise = null; + + const videos = await this.nostrService.loadVideos({ + forceFetch, + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + onVideos: (payload, detail = {}) => { + const promise = this.refreshKidsFeed({ + reason: detail?.reason, + fallbackVideos: payload, + }); + if (!initialRefreshPromise) { + initialRefreshPromise = promise; + } + }, + }); + + if (initialRefreshPromise) { + await initialRefreshPromise; + } else if (!Array.isArray(videos) || videos.length === 0) { + await this.refreshKidsFeed({ reason: "initial", fallbackVideos: [] }); + } + + this.videoSubscription = this.nostrService.getVideoSubscription() || null; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + } + async loadExploreVideos(forceFetch = false) { devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")"); this.setFeedTelemetryContext("explore"); diff --git a/js/hashChangeHandler.js b/js/hashChangeHandler.js index 9c05b155..72fd37b1 100644 --- a/js/hashChangeHandler.js +++ b/js/hashChangeHandler.js @@ -71,6 +71,10 @@ export function createHashChangeHandler({ typeof window !== "undefined" && window.location && window.location.hash ? window.location.hash : ""; + if (hash === "#kids") { + window.location.hash = "#view=kids"; + return; + } const match = hash.match(/^#view=([^&]+)/); try { diff --git a/js/kidsView.js b/js/kidsView.js new file mode 100644 index 00000000..878cde50 --- /dev/null +++ b/js/kidsView.js @@ -0,0 +1,119 @@ +import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; + +const VIDEO_CARD_SELECTOR = "[data-component=\"video-card\"]"; +const EMPTY_STATE_SELECTOR = "[data-kids-empty-state]"; +const LOADING_SELECTOR = ".sidebar-loading-wrapper"; + +class KidsView { + constructor({ document: doc = document } = {}) { + this.document = doc; + this.container = null; + this.observer = null; + this.handleMutation = this.handleMutation.bind(this); + } + + init() { + this.container = this.document?.getElementById("videoList") || null; + if (!this.container) { + return; + } + + this.observeContainer(); + this.updateEmptyState(); + + const infoTrigger = this.document.getElementById("kidsInfoTrigger"); + if (infoTrigger) { + attachFeedInfoPopover( + infoTrigger, + "Kid-friendly videos filtered for younger audiences." + ); + } + } + + observeContainer() { + if (!this.container || typeof MutationObserver === "undefined") { + return; + } + + if (this.observer) { + this.observer.disconnect(); + } + + this.observer = new MutationObserver(this.handleMutation); + this.observer.observe(this.container, { + childList: true, + subtree: true, + }); + } + + handleMutation() { + this.updateEmptyState(); + } + + updateEmptyState() { + if (!this.container) { + return; + } + + const hasVideos = Boolean(this.container.querySelector(VIDEO_CARD_SELECTOR)); + const isLoading = Boolean(this.container.querySelector(LOADING_SELECTOR)); + const existingEmptyState = this.container.querySelector(EMPTY_STATE_SELECTOR); + + if (isLoading) { + if (existingEmptyState) { + existingEmptyState.remove(); + } + return; + } + + if (hasVideos) { + if (existingEmptyState) { + existingEmptyState.remove(); + } + return; + } + + if (existingEmptyState) { + return; + } + + const emptyState = this.buildEmptyState(); + if (emptyState) { + this.container.innerHTML = ""; + this.container.appendChild(emptyState); + } + } + + buildEmptyState() { + if (!this.document) { + return null; + } + + const wrapper = this.document.createElement("div"); + wrapper.className = "col-span-full"; + wrapper.dataset.kidsEmptyState = "true"; + + const card = this.document.createElement("div"); + card.className = "card p-lg text-center"; + + const title = this.document.createElement("h3"); + title.className = "text-lg font-semibold text-text"; + title.textContent = "No kids videos available yet."; + + const copy = this.document.createElement("p"); + copy.className = "mt-2 text-sm text-muted"; + copy.textContent = "Check back soon for new kid-friendly videos."; + + card.appendChild(title); + card.appendChild(copy); + wrapper.appendChild(card); + + return wrapper; + } +} + +export function initKidsView() { + const view = new KidsView(); + view.init(); + return view; +} diff --git a/js/viewManager.js b/js/viewManager.js index 2522fcaa..874e7acf 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,6 +1,7 @@ // js/viewManager.js import { initChannelProfileView } from "./channelProfile.js"; import { initForYouView } from "./forYouView.js"; +import { initKidsView } from "./kidsView.js"; import { initExploreView } from "./exploreView.js"; import { subscriptions } from "./subscriptions.js"; import { getApplication } from "./applicationContext.js"; @@ -118,6 +119,20 @@ export const viewInitRegistry = { refreshApp.forceRefreshAllProfiles(); } }, + kids: () => { + initKidsView(); + const app = getApplication(); + if (app && typeof app.loadKidsVideos === "function") { + if (typeof app.mountVideoListView === "function") { + app.mountVideoListView(); + } + app.loadKidsVideos(); + } + const refreshApp = getApplication(); + if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") { + refreshApp.forceRefreshAllProfiles(); + } + }, explore: () => { initExploreView(); const app = getApplication(); diff --git a/views/kids.html b/views/kids.html new file mode 100644 index 00000000..535f08df --- /dev/null +++ b/views/kids.html @@ -0,0 +1,40 @@ + +
+
+

For Kids

+ +
+ +
+ +
+