Merge pull request #1565 from PR0M3TH3AN/codex/implement-kids-view-and-routing

Add Kids feed view and routing
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-23 02:09:25 -05:00
commit 9872781a84
6 files changed
+309

No files matched your search

+9
View File
@@ -54,6 +54,15 @@
/>
<span class="sidebar-text">For You</span>
</a>
<a href="#view=kids" class="sidebar-nav-link" title="For Kids">
<img
src="assets/svg/feed-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">For Kids</span>
</a>
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
<img
src="assets/svg/globe-icon.svg"
+122
View File
@@ -6086,6 +6086,23 @@ class Application {
};
}
buildKidsFeedRuntime() {
const blacklist =
this.blacklistedEventIds instanceof Set
? new Set(this.blacklistedEventIds)
: new Set();
const moderationThresholds = this.getActiveModerationThresholds();
return {
blacklistedEventIds: blacklist,
isAuthorBlocked: (pubkey) => 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");
+4
View File
@@ -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 {
+119
View File
@@ -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;
}
+15
View File
@@ -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();
+40
View File
@@ -0,0 +1,40 @@
<!-- views/kids.html -->
<section data-ds="new">
<div class="flex items-center gap-2 mb-4">
<h2 class="text-xl font-semibold text-text">For Kids</h2>
<button
type="button"
id="kidsInfoTrigger"
class="text-muted hover:text-text focus:outline-none transition-colors"
aria-label="How is this feed sorted?"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
</div>
<div id="videoList" class="bv-grid-video">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text">Loading kids videos…</span>
</div>
</div>
</div>
</section>