mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- Add `UI_FEEDBACK_DELAY_MS`, `DEBOUNCE_DELAY_MS`, and `NETWORK_RETRY_DELAY_MS` to `js/constants.js`. - Replace magic numbers (5000, 2000, 1500) with semantic constants in: - `js/ui/notificationController.js` - `js/ui/profileModal/ProfileDirectMessageActions.js` - `js/ui/urlHealthController.js` - `js/app/uiCoordinator.js` - `js/ui/components/VideoModal.js` - `js/ui/components/UploadModal.js` - `js/services/dmNostrService.js` - `js/services/hashtagPreferencesService.js` - `js/app/feedCoordinator.js` - `js/services/moderationService.js` Co-authored-by: PR0M3TH3AN <[email protected]>
1767 lines
56 KiB
JavaScript
1767 lines
56 KiB
JavaScript
// js/app/feedCoordinator.js
|
|
|
|
/**
|
|
* Feed sourcing, filtering, sorting, and telemetry orchestration.
|
|
*
|
|
* All module-level dependencies are injected from the Application
|
|
* composition root rather than imported at module scope.
|
|
*
|
|
* Methods use `this` which is bound to the Application instance.
|
|
*/
|
|
|
|
import { FEED_TYPES, STANDARD_TIMEOUT_MS, MEDIUM_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
|
|
|
|
/**
|
|
* @param {object} deps - Injected dependencies.
|
|
* @returns {object} Methods to be bound to the Application instance.
|
|
*/
|
|
export function createFeedCoordinator(deps) {
|
|
const {
|
|
devLogger,
|
|
userLogger,
|
|
nostrClient,
|
|
watchHistoryService,
|
|
subscriptions,
|
|
getSidebarLoadingMarkup,
|
|
pointerKey,
|
|
isValidMagnetUri,
|
|
readCachedUrlHealth,
|
|
persistUrlHealth,
|
|
// Feed engine factories
|
|
createActiveNostrSource,
|
|
createBlacklistFilterStage,
|
|
createDisinterestFilterStage,
|
|
createDedupeByRootStage,
|
|
createExploreDiversitySorter,
|
|
createExploreScorerStage,
|
|
createKidsAudienceFilterStage,
|
|
createKidsScorerStage,
|
|
createKidsScoreSorter,
|
|
createModerationStage,
|
|
createResolvePostedAtStage,
|
|
createTagPreferenceFilterStage,
|
|
createWatchHistorySuppressionStage,
|
|
createChronologicalSorter,
|
|
createSubscriptionAuthorsSource,
|
|
registerWatchHistoryFeed: registerWatchHistoryFeedFn,
|
|
} = deps;
|
|
|
|
return {
|
|
registerRecentFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition("recent")
|
|
: null;
|
|
if (existingDefinition) {
|
|
return existingDefinition;
|
|
}
|
|
|
|
try {
|
|
const app = this;
|
|
const resolveThresholdFromApp = (key) => ({ 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;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
};
|
|
return this.feedEngine.registerFeed("recent", {
|
|
source: createActiveNostrSource({ service: this.nostrService }),
|
|
stages: [
|
|
createBlacklistFilterStage({
|
|
shouldIncludeVideo: (video, options) =>
|
|
this.nostrService.shouldIncludeVideo(video, options),
|
|
}),
|
|
createDedupeByRootStage({
|
|
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
|
}),
|
|
createModerationStage({
|
|
getService: () => this.nostrService.getModerationService(),
|
|
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
|
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
|
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
|
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
|
}),
|
|
createResolvePostedAtStage(),
|
|
],
|
|
sorter: createChronologicalSorter(),
|
|
hooks: {
|
|
timestamps: {
|
|
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
|
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[Application] Failed to register recent feed:", error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Register the FEED_TYPES.FOR_YOU feed pipeline.
|
|
*/
|
|
registerForYouFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition(FEED_TYPES.FOR_YOU)
|
|
: null;
|
|
if (existingDefinition) {
|
|
return existingDefinition;
|
|
}
|
|
|
|
try {
|
|
const app = this;
|
|
const resolveThresholdFromApp = (key) => ({ 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;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
};
|
|
return this.feedEngine.registerFeed(FEED_TYPES.FOR_YOU, {
|
|
source: createActiveNostrSource({ service: this.nostrService }),
|
|
stages: [
|
|
// Note: Tag-preference filtering is consolidated in createTagPreferenceFilterStage
|
|
// so each feed has a single source of truth for interest-based inclusion/ranking.
|
|
createTagPreferenceFilterStage(),
|
|
createBlacklistFilterStage({
|
|
shouldIncludeVideo: (video, options) =>
|
|
this.nostrService.shouldIncludeVideo(video, options),
|
|
}),
|
|
createWatchHistorySuppressionStage(),
|
|
createDedupeByRootStage({
|
|
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
|
}),
|
|
createModerationStage({
|
|
getService: () => this.nostrService.getModerationService(),
|
|
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
|
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
|
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
|
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
|
}),
|
|
createResolvePostedAtStage(),
|
|
],
|
|
sorter: createChronologicalSorter(),
|
|
hooks: {
|
|
timestamps: {
|
|
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
|
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[Application] Failed to register for-you feed:", error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Register the FEED_TYPES.KIDS feed pipeline.
|
|
*/
|
|
registerKidsFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition(FEED_TYPES.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(FEED_TYPES.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 FEED_TYPES.EXPLORE feed pipeline.
|
|
*/
|
|
registerExploreFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition(FEED_TYPES.EXPLORE)
|
|
: null;
|
|
if (existingDefinition) {
|
|
return existingDefinition;
|
|
}
|
|
|
|
try {
|
|
const app = this;
|
|
const resolveThresholdFromApp = (key) => ({ 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;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
};
|
|
return this.feedEngine.registerFeed(FEED_TYPES.EXPLORE, {
|
|
source: createActiveNostrSource({ service: this.nostrService }),
|
|
stages: [
|
|
createDisinterestFilterStage(),
|
|
createBlacklistFilterStage({
|
|
shouldIncludeVideo: (video, options) =>
|
|
this.nostrService.shouldIncludeVideo(video, options),
|
|
}),
|
|
createDedupeByRootStage({
|
|
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
|
}),
|
|
createModerationStage({
|
|
getService: () => this.nostrService.getModerationService(),
|
|
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
|
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
|
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
|
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
|
}),
|
|
createResolvePostedAtStage(),
|
|
createExploreScorerStage(),
|
|
],
|
|
sorter: createExploreDiversitySorter(),
|
|
hooks: {
|
|
timestamps: {
|
|
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
|
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[Application] Failed to register explore feed:", error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
registerSubscriptionsFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition(FEED_TYPES.SUBSCRIPTIONS)
|
|
: null;
|
|
if (existingDefinition) {
|
|
return existingDefinition;
|
|
}
|
|
|
|
try {
|
|
const app = this;
|
|
const resolveThresholdFromApp = (key) => ({ 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;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
};
|
|
return this.feedEngine.registerFeed(FEED_TYPES.SUBSCRIPTIONS, {
|
|
source: createSubscriptionAuthorsSource({ service: this.nostrService }),
|
|
stages: [
|
|
// Note: Tag-preference filtering is consolidated in createTagPreferenceFilterStage
|
|
// so each feed has a single source of truth for interest-based inclusion/ranking.
|
|
createTagPreferenceFilterStage(),
|
|
createBlacklistFilterStage({
|
|
shouldIncludeVideo: (video, options) =>
|
|
this.nostrService.shouldIncludeVideo(video, options),
|
|
}),
|
|
createDedupeByRootStage({
|
|
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
|
}),
|
|
createModerationStage({
|
|
getService: () => this.nostrService.getModerationService(),
|
|
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
|
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
|
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
|
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
|
}),
|
|
createResolvePostedAtStage(),
|
|
],
|
|
sorter: createChronologicalSorter(),
|
|
hooks: {
|
|
subscriptions: {
|
|
resolveAuthors: () => subscriptions.getSubscribedAuthors(),
|
|
},
|
|
timestamps: {
|
|
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
|
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[Application] Failed to register subscriptions feed:", error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
registerWatchHistoryFeed() {
|
|
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
|
devLogger.warn("[Application] Cannot register watch-history feed: feedEngine not available.");
|
|
return null;
|
|
}
|
|
|
|
const existingDefinition =
|
|
typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition("watch-history")
|
|
: null;
|
|
if (existingDefinition) {
|
|
return existingDefinition;
|
|
}
|
|
|
|
try {
|
|
return registerWatchHistoryFeedFn(this.feedEngine, {
|
|
service: watchHistoryService,
|
|
nostr: this.nostrService,
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[Application] Failed to register watch history feed:", error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
buildForYouFeedRuntime({ watchHistoryItems = [] } = {}) {
|
|
const blacklist =
|
|
this.blacklistedEventIds instanceof Set
|
|
? new Set(this.blacklistedEventIds)
|
|
: new Set();
|
|
|
|
const preferenceSource =
|
|
this.hashtagPreferencesSnapshot &&
|
|
typeof this.hashtagPreferencesSnapshot === "object"
|
|
? this.hashtagPreferencesSnapshot
|
|
: typeof this.createHashtagPreferencesSnapshot === "function"
|
|
? this.createHashtagPreferencesSnapshot()
|
|
: typeof this.getHashtagPreferences === "function"
|
|
? this.getHashtagPreferences()
|
|
: {};
|
|
const { interests = [], disinterests = [] } = preferenceSource || {};
|
|
const preferencesAvailable = preferenceSource?.dataReady === true;
|
|
const moderationThresholds = this.getActiveModerationThresholds();
|
|
|
|
const watchedKeys = new Set();
|
|
if (Array.isArray(watchHistoryItems)) {
|
|
for (const item of watchHistoryItems) {
|
|
const key =
|
|
typeof item.pointerKey === "string" && item.pointerKey
|
|
? item.pointerKey
|
|
: pointerKey(item.pointer || item);
|
|
if (key) {
|
|
watchedKeys.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
|
tagPreferences: {
|
|
interests: Array.isArray(interests) ? [...interests] : [],
|
|
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
|
available: preferencesAvailable,
|
|
},
|
|
moderationThresholds: moderationThresholds
|
|
? { ...moderationThresholds }
|
|
: undefined,
|
|
watchHistory: {
|
|
shouldSuppress: (item) => {
|
|
const video = item?.video;
|
|
if (!video || typeof video !== "object") {
|
|
return false;
|
|
}
|
|
|
|
if (video.id) {
|
|
const eKey = pointerKey({ type: "e", value: video.id });
|
|
if (watchedKeys.has(eKey)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (
|
|
Number.isFinite(video.kind) &&
|
|
video.kind >= 30000 &&
|
|
video.kind < 40000 &&
|
|
typeof video.pubkey === "string"
|
|
) {
|
|
const dTag = Array.isArray(video.tags)
|
|
? video.tags.find((t) => Array.isArray(t) && t[0] === "d")
|
|
: null;
|
|
const dValue = dTag && typeof dTag[1] === "string" ? dTag[1] : "";
|
|
if (dValue) {
|
|
const aValue = `${video.kind}:${video.pubkey}:${dValue}`;
|
|
const aKey = pointerKey({ type: "a", value: aValue });
|
|
if (watchedKeys.has(aKey)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
};
|
|
},
|
|
|
|
buildExploreFeedRuntime() {
|
|
const exploreDataService =
|
|
this.exploreDataService && typeof this.exploreDataService === "object"
|
|
? this.exploreDataService
|
|
: null;
|
|
const blacklist =
|
|
this.blacklistedEventIds instanceof Set
|
|
? new Set(this.blacklistedEventIds)
|
|
: new Set();
|
|
|
|
const preferenceSource =
|
|
this.hashtagPreferencesSnapshot &&
|
|
typeof this.hashtagPreferencesSnapshot === "object"
|
|
? this.hashtagPreferencesSnapshot
|
|
: typeof this.createHashtagPreferencesSnapshot === "function"
|
|
? this.createHashtagPreferencesSnapshot()
|
|
: typeof this.getHashtagPreferences === "function"
|
|
? this.getHashtagPreferences()
|
|
: {};
|
|
const { interests = [], disinterests = [] } = preferenceSource || {};
|
|
const preferencesAvailable = preferenceSource?.dataReady === true;
|
|
const moderationThresholds = this.getActiveModerationThresholds();
|
|
|
|
const watchHistorySource =
|
|
exploreDataService && typeof exploreDataService.getWatchHistoryTagCounts === "function"
|
|
? exploreDataService.getWatchHistoryTagCounts()
|
|
: this.watchHistoryTagCounts;
|
|
const watchHistoryTagCounts =
|
|
watchHistorySource instanceof Map
|
|
? new Map(watchHistorySource)
|
|
: watchHistorySource && typeof watchHistorySource === "object"
|
|
? { ...watchHistorySource }
|
|
: undefined;
|
|
|
|
const exploreTagSource =
|
|
exploreDataService && typeof exploreDataService.getTagIdf === "function"
|
|
? exploreDataService.getTagIdf()
|
|
: this.exploreTagIdf;
|
|
const exploreTagIdf =
|
|
exploreTagSource instanceof Map
|
|
? new Map(exploreTagSource)
|
|
: exploreTagSource && typeof exploreTagSource === "object"
|
|
? { ...exploreTagSource }
|
|
: undefined;
|
|
|
|
return {
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
|
tagPreferences: {
|
|
interests: Array.isArray(interests) ? [...interests] : [],
|
|
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
|
available: preferencesAvailable,
|
|
},
|
|
watchHistoryTagCounts,
|
|
exploreTagIdf,
|
|
moderationThresholds: moderationThresholds
|
|
? { ...moderationThresholds }
|
|
: undefined,
|
|
};
|
|
},
|
|
|
|
buildRecentFeedRuntime() {
|
|
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,
|
|
};
|
|
},
|
|
|
|
buildKidsFeedRuntime() {
|
|
const blacklist =
|
|
this.blacklistedEventIds instanceof Set
|
|
? new Set(this.blacklistedEventIds)
|
|
: new Set();
|
|
|
|
const feedDefinition =
|
|
this.feedEngine && typeof this.feedEngine.getFeedDefinition === "function"
|
|
? this.feedEngine.getFeedDefinition(FEED_TYPES.KIDS)
|
|
: null;
|
|
const configDefaults =
|
|
feedDefinition && typeof feedDefinition.configDefaults === "object"
|
|
? feedDefinition.configDefaults
|
|
: {};
|
|
|
|
const runtimeOverrides =
|
|
this.kidsFeedRuntime && typeof this.kidsFeedRuntime === "object"
|
|
? this.kidsFeedRuntime
|
|
: {};
|
|
const runtimeConfig =
|
|
this.kidsFeedConfig && typeof this.kidsFeedConfig === "object"
|
|
? this.kidsFeedConfig
|
|
: {};
|
|
|
|
const resolveStringArray = (...candidates) => {
|
|
for (const candidate of candidates) {
|
|
if (!Array.isArray(candidate)) {
|
|
continue;
|
|
}
|
|
return candidate
|
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const disallowedWarnings = resolveStringArray(
|
|
runtimeOverrides.disallowedWarnings,
|
|
runtimeConfig.disallowedWarnings,
|
|
configDefaults.disallowedWarnings,
|
|
);
|
|
const kidsEducationalTags = resolveStringArray(
|
|
runtimeOverrides.kidsEducationalTags,
|
|
runtimeOverrides.educationalTags,
|
|
runtimeConfig.kidsEducationalTags,
|
|
runtimeConfig.educationalTags,
|
|
configDefaults.educationalTags,
|
|
);
|
|
const trustedAuthors = resolveStringArray(
|
|
runtimeOverrides.trustedAuthors,
|
|
runtimeConfig.trustedAuthors,
|
|
);
|
|
|
|
const ageGroupCandidates = [
|
|
runtimeOverrides.ageGroup,
|
|
runtimeConfig.ageGroup,
|
|
configDefaults.ageGroup,
|
|
];
|
|
let ageGroup = "";
|
|
for (const candidate of ageGroupCandidates) {
|
|
if (typeof candidate !== "string") {
|
|
continue;
|
|
}
|
|
const trimmed = candidate.trim();
|
|
if (trimmed) {
|
|
ageGroup = trimmed;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const runtimeModerationOverrides =
|
|
runtimeOverrides.moderationThresholds &&
|
|
typeof runtimeOverrides.moderationThresholds === "object"
|
|
? runtimeOverrides.moderationThresholds
|
|
: null;
|
|
const configModerationOverrides =
|
|
runtimeConfig.moderationThresholds &&
|
|
typeof runtimeConfig.moderationThresholds === "object"
|
|
? runtimeConfig.moderationThresholds
|
|
: null;
|
|
const kidsThresholdOverrides =
|
|
runtimeModerationOverrides || configModerationOverrides
|
|
? {
|
|
...(configModerationOverrides || {}),
|
|
...(runtimeModerationOverrides || {}),
|
|
}
|
|
: null;
|
|
|
|
const moderationThresholds = this.getActiveModerationThresholds();
|
|
const resolvedModerationThresholds =
|
|
moderationThresholds || kidsThresholdOverrides
|
|
? {
|
|
...(moderationThresholds || {}),
|
|
...(kidsThresholdOverrides || {}),
|
|
}
|
|
: undefined;
|
|
|
|
const parentalAllowlist = resolveStringArray(
|
|
runtimeOverrides.parentalAllowlist,
|
|
runtimeOverrides.allowlist,
|
|
runtimeConfig.parentalAllowlist,
|
|
runtimeConfig.allowlist,
|
|
);
|
|
const parentalBlocklist = resolveStringArray(
|
|
runtimeOverrides.parentalBlocklist,
|
|
runtimeOverrides.blocklist,
|
|
runtimeConfig.parentalBlocklist,
|
|
runtimeConfig.blocklist,
|
|
);
|
|
|
|
return {
|
|
blacklistedEventIds: blacklist,
|
|
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
|
disallowedWarnings,
|
|
kidsEducationalTags,
|
|
educationalTags: kidsEducationalTags,
|
|
trustedAuthors,
|
|
ageGroup: ageGroup || undefined,
|
|
moderationThresholds: resolvedModerationThresholds,
|
|
parentalAllowlist,
|
|
parentalBlocklist,
|
|
};
|
|
},
|
|
|
|
async refreshFeed(feedType, { reason, fallbackVideos } = {}) {
|
|
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
|
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
|
|
let runtime = {};
|
|
let metadataModifier = (m) => m;
|
|
|
|
// 1. Prepare Runtime
|
|
try {
|
|
switch (feedType) {
|
|
case FEED_TYPES.FOR_YOU: {
|
|
let watchHistoryItems = [];
|
|
try {
|
|
watchHistoryItems = await watchHistoryService.loadLatest(
|
|
undefined,
|
|
{ allowStale: true },
|
|
);
|
|
} catch (err) {
|
|
devLogger.warn(
|
|
"[Application] Failed to preload watch history for For You feed:",
|
|
err,
|
|
);
|
|
}
|
|
runtime = this.buildForYouFeedRuntime({ watchHistoryItems });
|
|
break;
|
|
}
|
|
case FEED_TYPES.KIDS:
|
|
runtime = this.buildKidsFeedRuntime();
|
|
metadataModifier = (m) => {
|
|
if (runtime?.ageGroup && !m.ageGroup)
|
|
m.ageGroup = runtime.ageGroup;
|
|
return m;
|
|
};
|
|
break;
|
|
case FEED_TYPES.EXPLORE:
|
|
runtime = this.buildExploreFeedRuntime();
|
|
metadataModifier = (m) => {
|
|
const next = { ...m };
|
|
if (!next.sortOrder) next.sortOrder = FEED_TYPES.EXPLORE;
|
|
next.preserveOrder = true;
|
|
return next;
|
|
};
|
|
break;
|
|
case "recent":
|
|
runtime = this.buildRecentFeedRuntime();
|
|
break;
|
|
default:
|
|
devLogger.warn(`[Application] Unknown feed type: ${feedType}`);
|
|
return Promise.resolve({
|
|
videos: fallback,
|
|
metadata: { reason: normalizedReason, engine: "unknown" },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
devLogger.error(
|
|
`[Application] Failed to build runtime for ${feedType}:`,
|
|
err,
|
|
);
|
|
}
|
|
|
|
// 2. Check Engine
|
|
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
|
|
const metadata = metadataModifier({
|
|
reason: normalizedReason,
|
|
engine: "unavailable",
|
|
});
|
|
this.latestFeedMetadata = metadata;
|
|
this.videosMap = this.nostrService.getVideosMap();
|
|
if (this.videoListView) {
|
|
this.videoListView.state.videosMap = this.videosMap;
|
|
}
|
|
// Telemetry update for unavailable engine?
|
|
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
|
|
this.updateFeedTelemetryMetadata(feedType, [], metadata);
|
|
}
|
|
this.renderVideoList({ videos: fallback, metadata });
|
|
return Promise.resolve({ videos: fallback, metadata });
|
|
}
|
|
|
|
// 3. Run Engine
|
|
return this.feedEngine
|
|
.run(feedType, { runtime })
|
|
.then((result) => {
|
|
const videos = Array.isArray(result?.videos) ? result.videos : [];
|
|
let metadata = { ...(result?.metadata || {}) };
|
|
if (normalizedReason) metadata.reason = normalizedReason;
|
|
metadata = metadataModifier(metadata);
|
|
|
|
this.latestFeedMetadata = metadata;
|
|
this.videosMap = this.nostrService.getVideosMap();
|
|
if (this.videoListView) {
|
|
this.videoListView.state.videosMap = this.videosMap;
|
|
}
|
|
|
|
const items = Array.isArray(result?.items) ? result.items : [];
|
|
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
|
|
this.updateFeedTelemetryMetadata(feedType, items, metadata);
|
|
}
|
|
|
|
const payload = { videos, metadata };
|
|
this.renderVideoList(payload);
|
|
return payload;
|
|
})
|
|
.catch((error) => {
|
|
devLogger.error(
|
|
`[Application] Failed to run ${feedType} feed:`,
|
|
error,
|
|
);
|
|
let metadata = {
|
|
reason: normalizedReason || `error:${feedType}-feed`,
|
|
error: true,
|
|
};
|
|
metadata = metadataModifier(metadata);
|
|
|
|
this.latestFeedMetadata = metadata;
|
|
this.videosMap = this.nostrService.getVideosMap();
|
|
if (this.videoListView) {
|
|
this.videoListView.state.videosMap = this.videosMap;
|
|
}
|
|
|
|
if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) {
|
|
this.updateFeedTelemetryMetadata(feedType, [], metadata);
|
|
}
|
|
|
|
const payload = { videos: fallback, metadata };
|
|
this.renderVideoList(payload);
|
|
return payload;
|
|
});
|
|
},
|
|
|
|
checkRelayHealthWarning() {
|
|
if (!nostrClient || typeof nostrClient.getHealthyRelays !== "function") {
|
|
return false;
|
|
}
|
|
|
|
// Give the app a grace period to establish initial connections before complaining.
|
|
const now = Date.now();
|
|
const GRACE_PERIOD_MS = STANDARD_TIMEOUT_MS;
|
|
if (now - (this.appStartedAt || now) < GRACE_PERIOD_MS) {
|
|
return false;
|
|
}
|
|
|
|
const relayCandidates = Array.isArray(nostrClient.relays) ? nostrClient.relays : [];
|
|
if (!relayCandidates.length) {
|
|
return false;
|
|
}
|
|
|
|
const healthyRelays = nostrClient.getHealthyRelays(relayCandidates);
|
|
if (healthyRelays.length) {
|
|
return false;
|
|
}
|
|
|
|
const cooldownMs = MEDIUM_TIMEOUT_MS;
|
|
if (now - (this.lastRelayHealthWarningAt || 0) < cooldownMs) {
|
|
return true;
|
|
}
|
|
|
|
this.lastRelayHealthWarningAt = now;
|
|
this.showStatus(
|
|
"All configured relays are unhealthy. Data may be missing until a relay reconnects.",
|
|
{ autoHideMs: 12000, showSpinner: false },
|
|
);
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Unified loader for all feed types.
|
|
*/
|
|
async loadFeedVideos(feedType, forceFetch = false) {
|
|
devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")");
|
|
this.setFeedTelemetryContext(feedType);
|
|
|
|
let includeTags = false;
|
|
let loadingMessage = "Fetching videos\u2026";
|
|
let refreshMethod = null;
|
|
let shouldCheckRelayHealth = false;
|
|
|
|
switch (feedType) {
|
|
case "recent":
|
|
includeTags = true;
|
|
loadingMessage = "Fetching recent videos\u2026";
|
|
refreshMethod = (opts) => this.refreshFeed("recent", opts);
|
|
shouldCheckRelayHealth = true;
|
|
break;
|
|
case FEED_TYPES.FOR_YOU:
|
|
includeTags = false;
|
|
loadingMessage = "Fetching for-you videos\u2026";
|
|
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.FOR_YOU, opts);
|
|
shouldCheckRelayHealth = true;
|
|
break;
|
|
case FEED_TYPES.KIDS:
|
|
includeTags = true;
|
|
loadingMessage = "Fetching kids videos\u2026";
|
|
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.KIDS, opts);
|
|
shouldCheckRelayHealth = false;
|
|
break;
|
|
case FEED_TYPES.EXPLORE:
|
|
includeTags = false;
|
|
loadingMessage = "Fetching explore videos\u2026";
|
|
refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts);
|
|
shouldCheckRelayHealth = false;
|
|
break;
|
|
default:
|
|
devLogger.warn(
|
|
`[Application] Unknown feed type for loadFeedVideos: ${feedType}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (shouldCheckRelayHealth) {
|
|
this.checkRelayHealthWarning();
|
|
}
|
|
|
|
const container = this.mountVideoListView({ includeTags });
|
|
const hasCachedVideos =
|
|
this.nostrService &&
|
|
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
|
|
this.nostrService.getFilteredActiveVideos().length > 0;
|
|
|
|
if (!hasCachedVideos) {
|
|
if (this.videoListView && container) {
|
|
this.videoListView.showLoading(loadingMessage);
|
|
} else if (container) {
|
|
container.innerHTML = getSidebarLoadingMarkup(loadingMessage);
|
|
}
|
|
}
|
|
|
|
let initialRefreshPromise = null;
|
|
|
|
const videos = await this.nostrService.loadVideos({
|
|
forceFetch,
|
|
blacklistedEventIds: this.blacklistedEventIds,
|
|
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
|
onVideos: (payload, detail = {}) => {
|
|
const promise = refreshMethod({
|
|
reason: detail?.reason,
|
|
fallbackVideos: payload,
|
|
});
|
|
if (!initialRefreshPromise) {
|
|
initialRefreshPromise = promise;
|
|
}
|
|
},
|
|
});
|
|
|
|
if (initialRefreshPromise) {
|
|
await initialRefreshPromise;
|
|
} else if (!Array.isArray(videos) || videos.length === 0) {
|
|
await refreshMethod({ reason: "initial", fallbackVideos: [] });
|
|
}
|
|
|
|
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
|
this.videosMap = this.nostrService.getVideosMap();
|
|
if (this.videoListView) {
|
|
this.videoListView.state.videosMap = this.videosMap;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Subscribe to videos (older + new) and render them as they come in.
|
|
*/
|
|
async loadVideos(forceFetch = false) {
|
|
if (this.loadVideosPromise && !forceFetch) {
|
|
devLogger.log("Reusing in-flight loadVideos request.");
|
|
return this.loadVideosPromise;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (this.lastLoadVideosTime && (now - this.lastLoadVideosTime < DEBOUNCE_DELAY_MS) && !forceFetch) {
|
|
devLogger.log("Skipping redundant loadVideos request (cooldown).");
|
|
return Promise.resolve();
|
|
}
|
|
this.lastLoadVideosTime = now;
|
|
|
|
this.loadVideosPromise = (async () => {
|
|
await this.loadFeedVideos("recent", forceFetch);
|
|
})();
|
|
|
|
try {
|
|
await this.loadVideosPromise;
|
|
} finally {
|
|
this.loadVideosPromise = null;
|
|
}
|
|
},
|
|
|
|
async loadForYouVideos(forceFetch = false) {
|
|
return this.loadFeedVideos(FEED_TYPES.FOR_YOU, forceFetch);
|
|
},
|
|
|
|
async loadKidsVideos(forceFetch = false) {
|
|
return this.loadFeedVideos(FEED_TYPES.KIDS, forceFetch);
|
|
},
|
|
|
|
async loadExploreVideos(forceFetch = false) {
|
|
return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch);
|
|
},
|
|
|
|
async loadOlderVideos(lastTimestamp) {
|
|
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
|
|
blacklistedEventIds: this.blacklistedEventIds,
|
|
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
|
});
|
|
|
|
if (!Array.isArray(olderVideos) || olderVideos.length === 0) {
|
|
this.showSuccess("No more older videos found.");
|
|
return;
|
|
}
|
|
|
|
await this.refreshFeed("recent", { reason: "older-fetch" });
|
|
},
|
|
|
|
/**
|
|
* Returns true if there's at least one strictly older version
|
|
* (same videoRootId, created_at < current) which is NOT deleted.
|
|
*/
|
|
hasOlderVersion(video, allEvents) {
|
|
if (!video || !video.videoRootId) return false;
|
|
|
|
const rootId = video.videoRootId;
|
|
const currentTs = video.created_at;
|
|
|
|
// among ALL known events (including overshadowed), find older, not deleted
|
|
const olderMatches = allEvents.filter(
|
|
(v) => v.videoRootId === rootId && v.created_at < currentTs && !v.deleted
|
|
);
|
|
return olderMatches.length > 0;
|
|
},
|
|
|
|
/**
|
|
* Centralised helper for other modules (channel profiles, subscriptions)
|
|
* so they can re-use the exact same badge skeleton. Keeping the markup in
|
|
* one place avoids subtle mismatches when we tweak copy or classes later.
|
|
*/
|
|
getUrlHealthPlaceholderMarkup(options = {}) {
|
|
return this.urlHealthController.getUrlHealthPlaceholderMarkup(options);
|
|
},
|
|
|
|
getTorrentHealthBadgeMarkup(options = {}) {
|
|
const includeMargin = options?.includeMargin !== false;
|
|
const classes = ["badge", "torrent-health-badge"];
|
|
if (includeMargin) {
|
|
classes.push("mt-sm");
|
|
}
|
|
|
|
return `
|
|
<span
|
|
class="${classes.join(" ")}"
|
|
data-stream-health-state="checking"
|
|
data-variant="neutral"
|
|
aria-live="polite"
|
|
role="status"
|
|
>
|
|
\u23F3 Torrent
|
|
</span>
|
|
`;
|
|
},
|
|
|
|
isMagnetUriSupported(magnet) {
|
|
return isValidMagnetUri(magnet);
|
|
},
|
|
|
|
getCachedUrlHealth(eventId, url) {
|
|
return readCachedUrlHealth(eventId, url);
|
|
},
|
|
|
|
storeUrlHealth(eventId, url, result, ttlMs) {
|
|
return persistUrlHealth(eventId, url, result, ttlMs);
|
|
},
|
|
|
|
updateUrlHealthBadge(badgeEl, state, videoId) {
|
|
return this.urlHealthController.updateUrlHealthBadge(badgeEl, state, videoId);
|
|
},
|
|
|
|
handleUrlHealthBadge(payload) {
|
|
return this.urlHealthController.handleUrlHealthBadge(payload);
|
|
},
|
|
|
|
handleStreamHealthBadgeUpdate(detail) {
|
|
if (!detail || typeof detail !== "object") {
|
|
return;
|
|
}
|
|
|
|
const card = detail.card;
|
|
if (!(card instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
let videoId =
|
|
(card.dataset && card.dataset.videoId) ||
|
|
(typeof card.getAttribute === "function" ? card.getAttribute("data-video-id") : "") ||
|
|
"";
|
|
|
|
if (!videoId && typeof card.querySelector === "function") {
|
|
const fallback = card.querySelector("[data-video-id]");
|
|
if (fallback instanceof HTMLElement && fallback.dataset.videoId) {
|
|
videoId = fallback.dataset.videoId;
|
|
}
|
|
}
|
|
|
|
if (!videoId) {
|
|
return;
|
|
}
|
|
|
|
if (this.videoListView && typeof this.videoListView.cacheStreamHealth === "function") {
|
|
this.videoListView.cacheStreamHealth(videoId, {
|
|
state: detail.state,
|
|
peers: detail.peers,
|
|
reason: detail.reason,
|
|
checkedAt: detail.checkedAt,
|
|
text: detail.text,
|
|
tooltip: detail.tooltip,
|
|
role: detail.role,
|
|
ariaLive: detail.ariaLive,
|
|
});
|
|
}
|
|
},
|
|
|
|
getFeedTelemetryState(feedName = "") {
|
|
if (!this.feedTelemetryState || typeof this.feedTelemetryState !== "object") {
|
|
this.feedTelemetryState = {
|
|
activeFeed: "",
|
|
feeds: new Map(),
|
|
};
|
|
}
|
|
|
|
if (!(this.feedTelemetryState.feeds instanceof Map)) {
|
|
this.feedTelemetryState.feeds = new Map();
|
|
}
|
|
|
|
const normalized =
|
|
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (this.feedTelemetryState.feeds.has(normalized)) {
|
|
return this.feedTelemetryState.feeds.get(normalized);
|
|
}
|
|
|
|
const state = {
|
|
matchedTagsById: new Map(),
|
|
matchReasonsById: new Map(),
|
|
kidsScoreById: new Map(),
|
|
moderationById: new Map(),
|
|
ageGroup: "",
|
|
lastImpressionSignature: "",
|
|
activePlayback: null,
|
|
};
|
|
this.feedTelemetryState.feeds.set(normalized, state);
|
|
return state;
|
|
},
|
|
|
|
setFeedTelemetryContext(feedName = "") {
|
|
const normalized =
|
|
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
|
const previousFeed = this.feedTelemetryState?.activeFeed || "";
|
|
if (previousFeed && previousFeed !== normalized) {
|
|
const previousState = this.getFeedTelemetryState(previousFeed);
|
|
if (previousState) {
|
|
previousState.lastImpressionSignature = "";
|
|
previousState.activePlayback = null;
|
|
}
|
|
}
|
|
|
|
const nextState = this.getFeedTelemetryState(normalized);
|
|
if (previousFeed !== normalized && nextState) {
|
|
nextState.lastImpressionSignature = "";
|
|
nextState.activePlayback = null;
|
|
}
|
|
|
|
this.feedTelemetryState.activeFeed = normalized;
|
|
},
|
|
|
|
isFeedActive(feedName = "") {
|
|
const normalized =
|
|
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
|
return Boolean(normalized && this.feedTelemetryState?.activeFeed === normalized);
|
|
},
|
|
|
|
isForYouFeedActive() {
|
|
return this.feedTelemetryState?.activeFeed === FEED_TYPES.FOR_YOU;
|
|
},
|
|
|
|
updateFeedTelemetryMetadata(feedName = "", items = [], metadata = {}) {
|
|
if (!this.isFeedActive(feedName)) {
|
|
return;
|
|
}
|
|
|
|
const feedState = this.getFeedTelemetryState(feedName);
|
|
if (!feedState) {
|
|
return;
|
|
}
|
|
|
|
const matchedTagsById = new Map();
|
|
const matchReasonsById = new Map();
|
|
const kidsScoreById = new Map();
|
|
const moderationById = new Map();
|
|
|
|
if (Array.isArray(items)) {
|
|
items.forEach((item) => {
|
|
const videoId =
|
|
typeof item?.video?.id === "string" ? item.video.id : "";
|
|
if (!videoId) {
|
|
return;
|
|
}
|
|
const matched =
|
|
Array.isArray(item?.metadata?.matchedInterests)
|
|
? item.metadata.matchedInterests
|
|
: [];
|
|
matchedTagsById.set(videoId, matched);
|
|
|
|
const kidsScoreRaw = Number(item?.metadata?.kidsScore);
|
|
if (Number.isFinite(kidsScoreRaw)) {
|
|
kidsScoreById.set(videoId, kidsScoreRaw);
|
|
}
|
|
|
|
const moderationPayload = this.buildModerationTelemetry(item?.video);
|
|
if (moderationPayload) {
|
|
moderationById.set(videoId, moderationPayload);
|
|
}
|
|
});
|
|
}
|
|
|
|
const whyEntries = Array.isArray(metadata?.why) ? metadata.why : [];
|
|
whyEntries.forEach((entry) => {
|
|
if (!entry || entry.reason !== "matched-interests") {
|
|
return;
|
|
}
|
|
const videoId = typeof entry.videoId === "string" ? entry.videoId : "";
|
|
if (!videoId) {
|
|
return;
|
|
}
|
|
const reasons = matchReasonsById.get(videoId) || [];
|
|
reasons.push({
|
|
stage: typeof entry.stage === "string" ? entry.stage : "",
|
|
reason: entry.reason,
|
|
});
|
|
matchReasonsById.set(videoId, reasons);
|
|
});
|
|
|
|
const ageGroup =
|
|
typeof metadata?.ageGroup === "string" ? metadata.ageGroup.trim() : "";
|
|
|
|
feedState.matchedTagsById = matchedTagsById;
|
|
feedState.matchReasonsById = matchReasonsById;
|
|
feedState.kidsScoreById = kidsScoreById;
|
|
feedState.moderationById = moderationById;
|
|
feedState.ageGroup = ageGroup;
|
|
},
|
|
|
|
updateForYouTelemetryMetadata(items = [], metadata = {}) {
|
|
this.updateFeedTelemetryMetadata(FEED_TYPES.FOR_YOU, items, metadata);
|
|
},
|
|
|
|
resolveVideoForTelemetry(videoId) {
|
|
if (typeof videoId !== "string" || !videoId) {
|
|
return null;
|
|
}
|
|
|
|
if (this.videosMap instanceof Map && this.videosMap.has(videoId)) {
|
|
return this.videosMap.get(videoId) || null;
|
|
}
|
|
|
|
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
|
|
return this.videoListView.currentVideos.find((video) => video?.id === videoId) || null;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
resolveVideoIndex(videoId) {
|
|
if (!this.videoListView || !Array.isArray(this.videoListView.currentVideos)) {
|
|
return null;
|
|
}
|
|
|
|
const index = this.videoListView.currentVideos.findIndex(
|
|
(video) => video?.id === videoId,
|
|
);
|
|
return index >= 0 ? index : null;
|
|
},
|
|
|
|
buildModerationTelemetry(video) {
|
|
if (!video || typeof video !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const moderation =
|
|
video.moderation && typeof video.moderation === "object"
|
|
? video.moderation
|
|
: null;
|
|
if (!moderation) {
|
|
return null;
|
|
}
|
|
|
|
const payload = {
|
|
hidden: moderation.hidden === true,
|
|
blurThumbnail: moderation.blurThumbnail === true,
|
|
blockAutoplay: moderation.blockAutoplay === true,
|
|
viewerOverride: moderation.viewerOverride?.showAnyway === true,
|
|
trustedMuted: moderation.trustedMuted === true,
|
|
};
|
|
|
|
const reportType =
|
|
typeof moderation.reportType === "string" ? moderation.reportType : "";
|
|
if (reportType) {
|
|
payload.reportType = reportType;
|
|
}
|
|
|
|
return payload;
|
|
},
|
|
|
|
buildFeedTelemetryPayload(feedName = "", { video, videoId, position } = {}) {
|
|
if (!this.isFeedActive(feedName)) {
|
|
return null;
|
|
}
|
|
|
|
const feedState = this.getFeedTelemetryState(feedName);
|
|
if (!feedState) {
|
|
return null;
|
|
}
|
|
|
|
const eventId =
|
|
typeof videoId === "string" && videoId
|
|
? videoId
|
|
: typeof video?.id === "string"
|
|
? video.id
|
|
: "";
|
|
if (!eventId) {
|
|
return null;
|
|
}
|
|
|
|
const payload = {
|
|
feed: feedName,
|
|
eventId,
|
|
videoId: eventId,
|
|
};
|
|
|
|
if (feedName === FEED_TYPES.FOR_YOU) {
|
|
const matchedTagsRaw = feedState.matchedTagsById?.get(eventId) || [];
|
|
const matchedTags = Array.isArray(matchedTagsRaw)
|
|
? Array.from(
|
|
new Set(
|
|
matchedTagsRaw
|
|
.filter((tag) => typeof tag === "string")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean),
|
|
),
|
|
)
|
|
: [];
|
|
|
|
const whyRaw = feedState.matchReasonsById?.get(eventId) || [];
|
|
const why = Array.isArray(whyRaw)
|
|
? whyRaw.map((entry) => ({
|
|
stage: typeof entry.stage === "string" ? entry.stage : "",
|
|
reason: typeof entry.reason === "string" ? entry.reason : "",
|
|
}))
|
|
: [];
|
|
|
|
payload.matchedTags = matchedTags;
|
|
payload.why = why;
|
|
}
|
|
|
|
const ageGroup =
|
|
typeof feedState.ageGroup === "string" ? feedState.ageGroup : "";
|
|
if (ageGroup) {
|
|
payload.ageGroup = ageGroup;
|
|
}
|
|
|
|
const kidsScore = feedState.kidsScoreById?.get(eventId);
|
|
if (Number.isFinite(kidsScore)) {
|
|
payload.kidsScore = kidsScore;
|
|
}
|
|
|
|
const moderationPayload =
|
|
this.buildModerationTelemetry(video) || feedState.moderationById?.get(eventId);
|
|
if (moderationPayload) {
|
|
payload.moderation = moderationPayload;
|
|
}
|
|
|
|
const videoRootId =
|
|
typeof video?.videoRootId === "string" ? video.videoRootId : "";
|
|
if (videoRootId) {
|
|
payload.videoRootId = videoRootId;
|
|
}
|
|
|
|
const pubkey = typeof video?.pubkey === "string" ? video.pubkey : "";
|
|
if (pubkey) {
|
|
payload.pubkey = pubkey;
|
|
}
|
|
|
|
if (Number.isFinite(position)) {
|
|
payload.position = Math.max(0, Math.floor(position));
|
|
}
|
|
|
|
return payload;
|
|
},
|
|
|
|
buildForYouTelemetryPayload({ video, videoId, position } = {}) {
|
|
return this.buildFeedTelemetryPayload(FEED_TYPES.FOR_YOU, {
|
|
video,
|
|
videoId,
|
|
position,
|
|
});
|
|
},
|
|
|
|
emitTelemetryEvent(eventName, payload) {
|
|
if (!eventName || !payload) {
|
|
return false;
|
|
}
|
|
|
|
const doc =
|
|
(this.videoModal && this.videoModal.document) ||
|
|
(typeof document !== "undefined" ? document : null);
|
|
|
|
if (!doc || typeof doc.dispatchEvent !== "function") {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
doc.dispatchEvent(
|
|
new CustomEvent("bitvid:telemetry", {
|
|
detail: { event: eventName, payload },
|
|
}),
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
userLogger.warn("[Application] Failed to emit telemetry event:", error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
resolveFeedTelemetryEventName(feedName = "", suffix = "") {
|
|
const normalized =
|
|
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
|
if (!normalized || !suffix) {
|
|
return "";
|
|
}
|
|
|
|
const prefixMap = new Map([
|
|
[FEED_TYPES.FOR_YOU, "for_you"],
|
|
[FEED_TYPES.KIDS, "kids_feed"],
|
|
]);
|
|
|
|
const prefix = prefixMap.get(normalized);
|
|
if (!prefix) {
|
|
return "";
|
|
}
|
|
|
|
return `${prefix}_${suffix}`;
|
|
},
|
|
|
|
emitFeedTelemetryEvent(
|
|
feedName = "",
|
|
eventName = "",
|
|
{ video, videoId, position } = {},
|
|
) {
|
|
const payload = this.buildFeedTelemetryPayload(feedName, {
|
|
video,
|
|
videoId,
|
|
position,
|
|
});
|
|
if (!payload) {
|
|
return false;
|
|
}
|
|
|
|
return this.emitTelemetryEvent(eventName, payload);
|
|
},
|
|
|
|
emitForYouTelemetryEvent(eventName, { video, videoId, position } = {}) {
|
|
const payload = this.buildForYouTelemetryPayload({
|
|
video,
|
|
videoId,
|
|
position,
|
|
});
|
|
if (!payload) {
|
|
return false;
|
|
}
|
|
|
|
return this.emitTelemetryEvent(eventName, payload);
|
|
},
|
|
|
|
emitFeedImpressions(videos = [], { feedName } = {}) {
|
|
const normalized =
|
|
typeof feedName === "string"
|
|
? feedName.trim().toLowerCase()
|
|
: this.feedTelemetryState?.activeFeed || "";
|
|
if (!normalized || !Array.isArray(videos)) {
|
|
return;
|
|
}
|
|
|
|
const feedState = this.getFeedTelemetryState(normalized);
|
|
if (!feedState) {
|
|
return;
|
|
}
|
|
|
|
const signature = videos
|
|
.map((video) => (typeof video?.id === "string" ? video.id : ""))
|
|
.filter(Boolean)
|
|
.join("|");
|
|
|
|
if (signature && signature === feedState.lastImpressionSignature) {
|
|
return;
|
|
}
|
|
|
|
feedState.lastImpressionSignature = signature;
|
|
|
|
const eventName = this.resolveFeedTelemetryEventName(normalized, "impression");
|
|
if (!eventName) {
|
|
return;
|
|
}
|
|
|
|
videos.forEach((video, index) => {
|
|
this.emitFeedTelemetryEvent(normalized, eventName, {
|
|
video,
|
|
videoId: video?.id,
|
|
position: index,
|
|
});
|
|
});
|
|
},
|
|
|
|
emitForYouImpressions(videos = []) {
|
|
this.emitFeedImpressions(videos, { feedName: FEED_TYPES.FOR_YOU });
|
|
},
|
|
|
|
recordFeedClick(videoId, { feedName } = {}) {
|
|
const normalized =
|
|
typeof feedName === "string"
|
|
? feedName.trim().toLowerCase()
|
|
: this.feedTelemetryState?.activeFeed || "";
|
|
if (!normalized || !videoId) {
|
|
return;
|
|
}
|
|
|
|
const feedState = this.getFeedTelemetryState(normalized);
|
|
if (!feedState) {
|
|
return;
|
|
}
|
|
|
|
const eventName = this.resolveFeedTelemetryEventName(normalized, "click");
|
|
if (!eventName) {
|
|
return;
|
|
}
|
|
|
|
const video = this.resolveVideoForTelemetry(videoId);
|
|
const position = this.resolveVideoIndex(videoId);
|
|
|
|
feedState.activePlayback = {
|
|
feed: normalized,
|
|
videoId,
|
|
};
|
|
|
|
this.emitFeedTelemetryEvent(normalized, eventName, {
|
|
video,
|
|
videoId,
|
|
position,
|
|
});
|
|
},
|
|
|
|
recordForYouClick(videoId) {
|
|
this.recordFeedClick(videoId, { feedName: FEED_TYPES.FOR_YOU });
|
|
},
|
|
|
|
handleFeedViewTelemetry(detail = {}) {
|
|
const activeFeed = this.feedTelemetryState?.activeFeed || "";
|
|
if (!activeFeed) {
|
|
return;
|
|
}
|
|
|
|
const feedState = this.getFeedTelemetryState(activeFeed);
|
|
if (!feedState) {
|
|
return;
|
|
}
|
|
|
|
const activePlayback = feedState.activePlayback;
|
|
if (!activePlayback || activePlayback.feed !== activeFeed) {
|
|
return;
|
|
}
|
|
|
|
const currentVideoId =
|
|
typeof this.currentVideo?.id === "string"
|
|
? this.currentVideo.id
|
|
: activePlayback.videoId;
|
|
if (!currentVideoId || currentVideoId !== activePlayback.videoId) {
|
|
return;
|
|
}
|
|
|
|
const pointerKey =
|
|
typeof detail?.pointerKey === "string" ? detail.pointerKey : "";
|
|
if (activePlayback.pointerKey && pointerKey) {
|
|
if (activePlayback.pointerKey !== pointerKey) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const video = this.resolveVideoForTelemetry(currentVideoId);
|
|
const payload = this.buildFeedTelemetryPayload(activeFeed, {
|
|
video,
|
|
videoId: currentVideoId,
|
|
});
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
if (pointerKey) {
|
|
payload.pointerKey = pointerKey;
|
|
}
|
|
|
|
const eventName = this.resolveFeedTelemetryEventName(activeFeed, "watch");
|
|
if (!eventName) {
|
|
return;
|
|
}
|
|
|
|
this.emitTelemetryEvent(eventName, payload);
|
|
feedState.activePlayback = null;
|
|
},
|
|
|
|
async renderVideoList(payload) {
|
|
if (!this.videoListView) {
|
|
return;
|
|
}
|
|
|
|
const container = this.mountVideoListView();
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
let videos = [];
|
|
let metadata = null;
|
|
|
|
if (Array.isArray(payload)) {
|
|
videos = payload;
|
|
} else if (payload && typeof payload === "object") {
|
|
if (Array.isArray(payload.videos)) {
|
|
videos = payload.videos;
|
|
}
|
|
if (payload.metadata && typeof payload.metadata === "object") {
|
|
metadata = { ...payload.metadata };
|
|
}
|
|
}
|
|
|
|
this.latestFeedMetadata = metadata;
|
|
if (this.videoListView) {
|
|
this.videoListView.state.feedMetadata = metadata;
|
|
}
|
|
|
|
const decoratedVideos = Array.isArray(videos)
|
|
? videos.map((video) => {
|
|
const moderated = this.decorateVideoModeration(video);
|
|
const targetVideo =
|
|
moderated && typeof moderated === "object" ? moderated : video;
|
|
const withIdentity = this.decorateVideoCreatorIdentity(targetVideo);
|
|
return withIdentity && typeof withIdentity === "object"
|
|
? withIdentity
|
|
: targetVideo;
|
|
})
|
|
: [];
|
|
|
|
this.videoListView.render(decoratedVideos, metadata);
|
|
this.emitFeedImpressions(decoratedVideos);
|
|
this.updateModalSimilarContent();
|
|
},
|
|
|
|
refreshVideoDiscussionCounts(videos = [], options = {}) {
|
|
if (!this.discussionCountService) {
|
|
return;
|
|
}
|
|
|
|
const { videoListRoot = this.videoList || null } = options;
|
|
|
|
this.discussionCountService.refreshCounts(videos, {
|
|
videoListRoot,
|
|
nostrClient,
|
|
});
|
|
},
|
|
};
|
|
}
|