diff --git a/docs/feed-engine.md b/docs/feed-engine.md index 3c385679..8545b1e8 100644 --- a/docs/feed-engine.md +++ b/docs/feed-engine.md @@ -68,6 +68,8 @@ constructed today behave exactly like the existing UI. | --- | --- | | `createDedupeByRootStage` | Reuses the application’s `dedupeVideosByRoot` helper (falling back to `dedupeToNewestByRoot`) to drop older versions of the same videoRoot. Adds "why" metadata for removed entries. | | `createBlacklistFilterStage` | Calls `nostrService.shouldIncludeVideo(...)` so moderators and block lists stay enforced. Each rejection logs a "blacklist" reason in the why-trail. | +| `createTagPreferenceFilterStage` | Normalizes interest/disinterest tags via `normalizeHashtag`, drops videos that match disinterests, and (by default) filters out videos that do not match interest tags. Set `enforceInterests: false` to keep items even when they lack interest tags. | +| `createDisinterestFilterStage` | Normalizes tag preferences via `normalizeHashtag` and drops videos that match disinterests without excluding items that lack interest tags (useful for Explore-style feeds). | | `createWatchHistorySuppressionStage` | Invokes feed-provided hooks to optionally suppress watched items. Useful for per-feed watch history preferences. | Stages receive `(items, context)` and should return the transformed list. They diff --git a/js/app.js b/js/app.js index dd76de17..e0a7a818 100644 --- a/js/app.js +++ b/js/app.js @@ -36,6 +36,7 @@ import { createFeedEngine, createActiveNostrSource, createBlacklistFilterStage, + createDisinterestFilterStage, createDedupeByRootStage, createExploreDiversitySorter, createExploreScorerStage, @@ -5647,10 +5648,7 @@ class Application { return this.feedEngine.registerFeed("explore", { source: createActiveNostrSource({ service: this.nostrService }), stages: [ - createTagPreferenceFilterStage({ - stageName: "tag-disinterest-filter", - matchInterests: false, - }), + createDisinterestFilterStage(), createBlacklistFilterStage({ shouldIncludeVideo: (video, options) => this.nostrService.shouldIncludeVideo(video, options), diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index f02a15ce..92d24f75 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -4,6 +4,7 @@ export { createFeedEngine } from "./engine.js"; export { createDedupeByRootStage, createBlacklistFilterStage, + createDisinterestFilterStage, createTagPreferenceFilterStage, createWatchHistorySuppressionStage, createModerationStage, diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 5a8fe08e..ffd7fc18 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -275,107 +275,146 @@ export function createResolvePostedAtStage({ export function createTagPreferenceFilterStage({ stageName = "tag-preference-filter", + enforceInterests, matchInterests = true, } = {}) { + const shouldMatchInterests = + typeof enforceInterests === "boolean" + ? enforceInterests + : matchInterests !== false; + return async function tagPreferenceFilterStage(items = [], context = {}) { - const shouldMatchInterests = matchInterests !== false; - const tagPreferences = context?.runtime?.tagPreferences; - const interests = normalizeTagSet(tagPreferences?.interests); - const disinterests = normalizeTagSet(tagPreferences?.disinterests); - if (!interests.size && !disinterests.size) { - return items; - } - - const results = []; - - for (const item of items) { - const video = item?.video; - if (!video || typeof video !== "object") { - results.push(item); - continue; - } - - const videoTags = new Set(); - if (Array.isArray(video.tags)) { - for (const tag of video.tags) { - if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") { - const normalized = normalizeHashtag(tag[1]); - if (normalized) { - videoTags.add(normalized); - } - } - } - } - - if (Array.isArray(video.nip71?.hashtags)) { - for (const tag of video.nip71.hashtags) { - if (typeof tag === "string") { - const normalized = normalizeHashtag(tag); - if (normalized) { - videoTags.add(normalized); - } - } - } - } - - let disinterested = false; - if (disinterests.size) { - for (const tag of videoTags) { - if (disinterests.has(tag)) { - disinterested = true; - break; - } - } - } - - if (disinterested) { - context?.addWhy?.({ - stage: stageName, - type: "filter", - reason: "disinterested-tag", - videoId: typeof video.id === "string" ? video.id : null, - pubkey: typeof video.pubkey === "string" ? video.pubkey : null, - }); - continue; - } - - let matchedInterests = []; - if (interests.size) { - matchedInterests = [...videoTags].filter((tag) => interests.has(tag)); - if (shouldMatchInterests && matchedInterests.length === 0) { - context?.addWhy?.({ - stage: stageName, - type: "filter", - reason: "no-interest-match", - videoId: typeof video.id === "string" ? video.id : null, - pubkey: typeof video.pubkey === "string" ? video.pubkey : null, - }); - continue; - } - } - - if (matchedInterests.length > 0) { - if (!isPlainObject(item.metadata)) { - item.metadata = {}; - } - item.metadata.matchedInterests = matchedInterests; - context?.addWhy?.({ - stage: stageName, - type: "filter", - reason: "matched-interests", - videoId: typeof video.id === "string" ? video.id : null, - pubkey: typeof video.pubkey === "string" ? video.pubkey : null, - tags: matchedInterests, - }); - } - - results.push(item); - } - - return results; + return filterByTagPreferences({ + items, + context, + stageName, + enforceInterests: shouldMatchInterests, + }); }; } +export function createDisinterestFilterStage({ + stageName = "tag-disinterest-filter", +} = {}) { + return async function disinterestFilterStage(items = [], context = {}) { + return filterByTagPreferences({ + items, + context, + stageName, + enforceInterests: false, + }); + }; +} + +function collectVideoTags(video) { + const videoTags = new Set(); + + if (Array.isArray(video.tags)) { + for (const tag of video.tags) { + if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") { + const normalized = normalizeHashtag(tag[1]); + if (normalized) { + videoTags.add(normalized); + } + } + } + } + + if (Array.isArray(video.nip71?.hashtags)) { + for (const tag of video.nip71.hashtags) { + if (typeof tag === "string") { + const normalized = normalizeHashtag(tag); + if (normalized) { + videoTags.add(normalized); + } + } + } + } + + return videoTags; +} + +function filterByTagPreferences({ + items = [], + context = {}, + stageName, + enforceInterests, +}) { + const tagPreferences = context?.runtime?.tagPreferences; + const interests = normalizeTagSet(tagPreferences?.interests); + const disinterests = normalizeTagSet(tagPreferences?.disinterests); + if (!interests.size && !disinterests.size) { + return items; + } + + const results = []; + + for (const item of items) { + const video = item?.video; + if (!video || typeof video !== "object") { + results.push(item); + continue; + } + + const videoTags = collectVideoTags(video); + + let disinterested = false; + if (disinterests.size) { + for (const tag of videoTags) { + if (disinterests.has(tag)) { + disinterested = true; + break; + } + } + } + + if (disinterested) { + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "disinterested-tag", + videoId: typeof video.id === "string" ? video.id : null, + pubkey: typeof video.pubkey === "string" ? video.pubkey : null, + }); + continue; + } + + let matchedInterests = []; + if (interests.size) { + matchedInterests = [...videoTags].filter((tag) => interests.has(tag)); + if (enforceInterests && matchedInterests.length === 0) { + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "no-interest-match", + videoId: typeof video.id === "string" ? video.id : null, + pubkey: typeof video.pubkey === "string" ? video.pubkey : null, + }); + continue; + } + } + + if (matchedInterests.length > 0) { + if (!isPlainObject(item.metadata)) { + item.metadata = {}; + } + item.metadata.matchedInterests = matchedInterests; + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "matched-interests", + videoId: typeof video.id === "string" ? video.id : null, + pubkey: typeof video.pubkey === "string" ? video.pubkey : null, + tags: matchedInterests, + }); + } + + results.push(item); + } + + return results; +} + export function createBlacklistFilterStage({ stageName = "blacklist-filter", shouldIncludeVideo,