diff --git a/js/app.js b/js/app.js index 59e78f5c..aef0c6c3 100644 --- a/js/app.js +++ b/js/app.js @@ -40,6 +40,9 @@ import { createDedupeByRootStage, createExploreDiversitySorter, createExploreScorerStage, + createKidsAudienceFilterStage, + createKidsScorerStage, + createKidsScoreSorter, createModerationStage, createResolvePostedAtStage, createTagPreferenceFilterStage, @@ -5662,6 +5665,147 @@ class Application { } } + /** + * Register the "kids" feed pipeline. + */ + registerKidsFeed() { + if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + return null; + } + + const existingDefinition = + typeof this.feedEngine.getFeedDefinition === "function" + ? this.feedEngine.getFeedDefinition("kids") + : null; + if (existingDefinition) { + return existingDefinition; + } + + try { + const app = this; + const resolveThresholdFromApp = (key, kidsDefault) => ({ + runtimeValue, + defaultValue, + }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + + if (app && typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = active && typeof active === "object" ? active[key] : undefined; + if ( + Number.isFinite(candidate) || + candidate === Number.POSITIVE_INFINITY + ) { + return candidate; + } + } + + if ( + Number.isFinite(kidsDefault) || + kidsDefault === Number.POSITIVE_INFINITY + ) { + return kidsDefault; + } + + return defaultValue; + }; + + const kidsDefaults = { + blurThreshold: 1, + trustedReportHideThreshold: 1, + trustedMuteHideThreshold: 1, + }; + + const disallowedWarnings = [ + "nudity", + "sexual", + "graphic-violence", + "self-harm", + "drugs", + ]; + + const moderationStages = ["nudity", "violence", "self-harm"].map( + (reportType) => + createModerationStage({ + stageName: `kids-moderation-${reportType}`, + reportType, + getService: () => this.nostrService.getModerationService(), + autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"), + blurThreshold: resolveThresholdFromApp( + "blurThreshold", + kidsDefaults.blurThreshold, + ), + trustedMuteHideThreshold: resolveThresholdFromApp( + "trustedMuteHideThreshold", + kidsDefaults.trustedMuteHideThreshold, + ), + trustedReportHideThreshold: resolveThresholdFromApp( + "trustedSpamHideThreshold", + kidsDefaults.trustedReportHideThreshold, + ), + }), + ); + + return this.feedEngine.registerFeed("kids", { + source: createActiveNostrSource({ service: this.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + this.nostrService.shouldIncludeVideo(video, options), + }), + createKidsAudienceFilterStage({ + disallowedWarnings, + }), + ...moderationStages, + createResolvePostedAtStage(), + createDedupeByRootStage({ + dedupe: (videos) => this.dedupeVideosByRoot(videos), + }), + createKidsScorerStage(), + ], + sorter: createKidsScoreSorter(), + defaultConfig: { + ageGroup: "preschool", + educationalTags: [], + disallowedWarnings, + }, + configSchema: { + ageGroup: { + type: "enum", + values: ["toddler", "preschool", "early", "older"], + description: "Target age group used for kids scoring defaults.", + default: "preschool", + }, + educationalTags: { + type: "string[]", + description: "Optional educational tag overrides for kids scoring.", + default: [], + }, + disallowedWarnings: { + type: "string[]", + description: + "Content warnings that should exclude videos from the kids feed.", + default: disallowedWarnings, + }, + }, + hooks: { + timestamps: { + getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video), + }, + }, + }); + } catch (error) { + devLogger.warn("[Application] Failed to register kids feed:", error); + return null; + } + } + /** * Register the "explore" feed pipeline. */ diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index ff59d344..47c10107 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -16,6 +16,7 @@ export { createKidsAudienceFilterStage } from "./kidsAudienceFilterStage.js"; export { createChronologicalSorter, createExploreDiversitySorter, + createKidsScoreSorter, } from "./sorters.js"; export { createActiveNostrSource, diff --git a/js/feedEngine/kidsScoring.js b/js/feedEngine/kidsScoring.js index 3fcc4a4e..77c9ee7d 100644 --- a/js/feedEngine/kidsScoring.js +++ b/js/feedEngine/kidsScoring.js @@ -250,15 +250,48 @@ export function createKidsScorerStage({ weights, ...options } = {}) { + const resolveAgeGroupFromContext = (context) => { + const candidates = [context?.runtime?.ageGroup, context?.config?.ageGroup, ageGroup]; + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + return trimmed; + } + } + return ageGroup; + }; + + const resolveEducationalTagsFromContext = (context) => { + const candidates = [ + context?.runtime?.educationalTags, + context?.config?.educationalTags, + educationalTags, + ]; + for (const candidate of candidates) { + if (candidate !== undefined && candidate !== null) { + return candidate; + } + } + return null; + }; + return async function kidsScorerStage(items = [], context = {}) { if (!Array.isArray(items) || items.length === 0) { return items; } - const ageProfile = resolveAgeProfile(ageGroup); + const resolvedAgeGroup = resolveAgeGroupFromContext(context); + const ageProfile = resolveAgeProfile(resolvedAgeGroup); const preferredTags = normalizeTagSet(ageProfile.preferredTags); const defaultEducationalTags = normalizeTagSet(ageProfile.educationalTags); - const educationalTagSet = educationalTags ? normalizeTagSet(educationalTags) : defaultEducationalTags; + const resolvedEducationalTags = resolveEducationalTagsFromContext(context); + const educationalTagSet = + resolvedEducationalTags !== null + ? normalizeTagSet(resolvedEducationalTags) + : defaultEducationalTags; const resolvedWeights = resolveWeights({ weights, ...options }, context); const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context); const popularityMax = resolvePopularityMax(options, context); diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index 459d396a..c4620509 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -411,3 +411,50 @@ export function createExploreDiversitySorter({ return selected.concat(orderedRest); }; } + +export function createKidsScoreSorter({ + stageName = "kids-score-sorter", +} = {}) { + return function kidsScoreSorter(items = [], context = {}) { + if (!Array.isArray(items)) { + return []; + } + + const copy = [...items]; + copy.sort((a, b) => { + const aScore = Number(a?.metadata?.kidsScore); + const bScore = Number(b?.metadata?.kidsScore); + const aHasScore = Number.isFinite(aScore); + const bHasScore = Number.isFinite(bScore); + const normalizedAScore = aHasScore ? aScore : Number.NEGATIVE_INFINITY; + const normalizedBScore = bHasScore ? bScore : Number.NEGATIVE_INFINITY; + + if (normalizedAScore !== normalizedBScore) { + return normalizedBScore - normalizedAScore; + } + + const aRootCreatedAt = Number(a?.video?.rootCreatedAt); + const bRootCreatedAt = Number(b?.video?.rootCreatedAt); + const normalizedARoot = + Number.isFinite(aRootCreatedAt) ? aRootCreatedAt : Number.NEGATIVE_INFINITY; + const normalizedBRoot = + Number.isFinite(bRootCreatedAt) ? bRootCreatedAt : Number.NEGATIVE_INFINITY; + + if (normalizedARoot !== normalizedBRoot) { + return normalizedBRoot - normalizedARoot; + } + + return stableVideoId(a).localeCompare(stableVideoId(b)); + }); + + if (context?.addWhy && typeof context.addWhy === "function") { + context.addWhy({ + stage: stageName, + type: "sort", + reason: "kids-score", + }); + } + + return copy; + }; +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 1538f551..94058a69 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -266,6 +266,7 @@ export default class ApplicationBootstrap { } app.registerRecentFeed(); app.registerForYouFeed(); + app.registerKidsFeed(); app.registerExploreFeed(); app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed();