mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
### Motivation
- Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient.
- Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured.
### Description
- Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes.
- Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util.
- Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient.
- Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`.
- Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread.
### Testing
- Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed.
- Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.
(cherry picked from commit ce14004725)
322 lines
9.5 KiB
JavaScript
322 lines
9.5 KiB
JavaScript
// js/feedEngine/sources.js
|
|
|
|
import nostrService from "../services/nostrService.js";
|
|
import watchHistoryService from "../watchHistoryService.js";
|
|
import { isPlainObject, toArray, toSet } from "./utils.js";
|
|
|
|
function resolveService(candidate, fallback) {
|
|
if (candidate && typeof candidate === "object") {
|
|
return candidate;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeAuthor(value) {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed.toLowerCase() : "";
|
|
}
|
|
|
|
function normalizeActorCandidate(...values) {
|
|
for (const value of values) {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function createActiveNostrSource({ service } = {}) {
|
|
const resolvedService = resolveService(service, nostrService);
|
|
|
|
return async function activeNostrSource(context = {}) {
|
|
const options = {
|
|
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
|
isAuthorBlocked:
|
|
typeof context?.runtime?.isAuthorBlocked === "function"
|
|
? context.runtime.isAuthorBlocked
|
|
: () => false,
|
|
};
|
|
|
|
let videos = [];
|
|
try {
|
|
videos = await Promise.resolve(
|
|
resolvedService.getFilteredActiveVideos(options)
|
|
);
|
|
} catch (error) {
|
|
context?.log?.("[active-source] Failed to resolve active videos", error);
|
|
videos = [];
|
|
}
|
|
|
|
return (Array.isArray(videos) ? videos : []).map((video) => ({
|
|
video,
|
|
metadata: {
|
|
source: "nostr:active",
|
|
},
|
|
}));
|
|
};
|
|
}
|
|
|
|
// Bounded discovery source for curated surfaces such as Most Zapped. It never
|
|
// falls back to an unscoped relay request: the caller must provide approved
|
|
// creator pubkeys in runtime.whitelistedAuthors. The successful remote result
|
|
// is retained briefly so an asynchronous ranking update does not turn into a
|
|
// second full relay scan for the same author set.
|
|
export function createWhitelistedAuthorsNostrSource({
|
|
service,
|
|
refreshTtlMs = 2 * 60 * 1000,
|
|
now = () => Date.now(),
|
|
} = {}) {
|
|
const resolvedService = resolveService(service, nostrService);
|
|
let cachedAuthorsKey = "";
|
|
let cachedVideos = [];
|
|
let fetchedAt = 0;
|
|
let inFlight = null;
|
|
|
|
const normalizeAuthors = (input) =>
|
|
Array.from(
|
|
new Set(
|
|
toArray(input)
|
|
.map(normalizeAuthor)
|
|
.filter(Boolean),
|
|
),
|
|
).sort();
|
|
|
|
return async function whitelistedAuthorsNostrSource(context = {}) {
|
|
const authors = normalizeAuthors(context?.runtime?.whitelistedAuthors);
|
|
const authorsKey = authors.join(",");
|
|
if (!authors.length) {
|
|
cachedAuthorsKey = "";
|
|
cachedVideos = [];
|
|
fetchedAt = 0;
|
|
return [];
|
|
}
|
|
|
|
const cacheIsCurrent =
|
|
authorsKey === cachedAuthorsKey &&
|
|
now() - fetchedAt < Math.max(0, Number(refreshTtlMs) || 0);
|
|
|
|
if (!cacheIsCurrent && !inFlight) {
|
|
const fetchByAuthors = resolvedService?.fetchVideosByAuthors;
|
|
inFlight = Promise.resolve(
|
|
typeof fetchByAuthors === "function"
|
|
? fetchByAuthors.call(resolvedService, authors)
|
|
: [],
|
|
)
|
|
.then((videos) => {
|
|
const allowed = new Set(authors);
|
|
cachedAuthorsKey = authorsKey;
|
|
cachedVideos = (Array.isArray(videos) ? videos : []).filter(
|
|
(video) => allowed.has(normalizeAuthor(video?.pubkey)),
|
|
);
|
|
fetchedAt = now();
|
|
return cachedVideos;
|
|
})
|
|
.catch((error) => {
|
|
context?.log?.(
|
|
"[whitelisted-authors-source] Failed to fetch approved creators",
|
|
error,
|
|
);
|
|
return authorsKey === cachedAuthorsKey ? cachedVideos : [];
|
|
})
|
|
.finally(() => {
|
|
inFlight = null;
|
|
});
|
|
}
|
|
|
|
const videos = cacheIsCurrent ? cachedVideos : await inFlight;
|
|
// The whitelist may change while a prior author-scoped request is still in
|
|
// flight. Filter again at return time so that stale results can never
|
|
// briefly render after an administrator removes a creator.
|
|
const allowed = new Set(authors);
|
|
return (Array.isArray(videos) ? videos : [])
|
|
.filter((video) => allowed.has(normalizeAuthor(video?.pubkey)))
|
|
.map((video) => ({
|
|
video,
|
|
metadata: {
|
|
source: "nostr:whitelisted-authors",
|
|
},
|
|
}));
|
|
};
|
|
}
|
|
|
|
export function createSubscriptionAuthorsSource({ service } = {}) {
|
|
const resolvedService = resolveService(service, nostrService);
|
|
|
|
return async function subscriptionAuthorsSource(context = {}) {
|
|
context?.log?.("[subscriptions-source] START");
|
|
const runtimeAuthors = toArray(
|
|
context?.runtime?.subscriptionAuthors || context?.runtime?.authors
|
|
).map(normalizeAuthor);
|
|
const configAuthors = toArray(context?.config?.actorFilters).map(
|
|
normalizeAuthor
|
|
);
|
|
const hookAuthors = [];
|
|
|
|
const hook = context?.hooks?.subscriptions;
|
|
if (isPlainObject(hook) && typeof hook.resolveAuthors === "function") {
|
|
try {
|
|
const resolved = await hook.resolveAuthors(context);
|
|
hookAuthors.push(...toArray(resolved).map(normalizeAuthor));
|
|
} catch (error) {
|
|
context?.log?.("[subscriptions-source] resolveAuthors threw", error);
|
|
}
|
|
}
|
|
|
|
const authors = new Set(
|
|
[...runtimeAuthors, ...configAuthors, ...hookAuthors].filter(Boolean)
|
|
);
|
|
|
|
context?.log?.(`[subscriptions-source] Resolved ${authors.size} authors.`);
|
|
|
|
if (!authors.size) {
|
|
return [];
|
|
}
|
|
|
|
const limitCandidate = Number(context?.runtime?.limit);
|
|
const limit =
|
|
Number.isFinite(limitCandidate) && limitCandidate > 0
|
|
? Math.floor(limitCandidate)
|
|
: null;
|
|
|
|
const options = {
|
|
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
|
|
isAuthorBlocked:
|
|
typeof context?.runtime?.isAuthorBlocked === "function"
|
|
? context.runtime.isAuthorBlocked
|
|
: () => false,
|
|
limit,
|
|
};
|
|
|
|
const authorList = Array.from(authors);
|
|
const hasTargetedLookup =
|
|
resolvedService &&
|
|
typeof resolvedService.getActiveVideosByAuthors === "function";
|
|
|
|
let videos = [];
|
|
try {
|
|
if (hasTargetedLookup) {
|
|
context?.log?.("[subscriptions-source] Calling getActiveVideosByAuthors...");
|
|
videos = await Promise.resolve(
|
|
resolvedService.getActiveVideosByAuthors(authorList, options)
|
|
);
|
|
context?.log?.(`[subscriptions-source] getActiveVideosByAuthors returned ${videos.length} videos.`);
|
|
|
|
if (!videos.length && typeof resolvedService.fetchVideosByAuthors === "function") {
|
|
context?.log?.("[subscriptions-source] Cache empty. Calling fetchVideosByAuthors...");
|
|
const fetched = await resolvedService.fetchVideosByAuthors(authorList, options);
|
|
context?.log?.(`[subscriptions-source] fetchVideosByAuthors returned ${fetched ? fetched.length : 0} videos.`);
|
|
if (Array.isArray(fetched) && fetched.length > 0) {
|
|
videos = fetched;
|
|
}
|
|
}
|
|
} else {
|
|
videos = await Promise.resolve(
|
|
resolvedService.getFilteredActiveVideos(options)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[subscriptions-source] Failed to resolve videos from nostrService",
|
|
error
|
|
);
|
|
videos = [];
|
|
}
|
|
|
|
const filtered = (Array.isArray(videos) ? videos : []).filter((video) => {
|
|
const author = normalizeAuthor(video?.pubkey);
|
|
return author && authors.has(author);
|
|
});
|
|
|
|
const sorted = filtered
|
|
.slice()
|
|
.sort(
|
|
(a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0)
|
|
);
|
|
|
|
const limited = limit ? sorted.slice(0, limit) : sorted;
|
|
|
|
return limited.map((video) => ({
|
|
video,
|
|
metadata: {
|
|
source: "nostr:subscriptions",
|
|
matchedAuthor: normalizeAuthor(video?.pubkey),
|
|
},
|
|
}));
|
|
};
|
|
}
|
|
|
|
export function createWatchHistoryPointerSource({ service } = {}) {
|
|
const resolvedService = resolveService(service, watchHistoryService);
|
|
|
|
return async function watchHistoryPointerSource(context = {}) {
|
|
const actor = normalizeActorCandidate(
|
|
context?.config?.actor,
|
|
context?.runtime?.watchHistory?.actor,
|
|
context?.runtime?.actor
|
|
);
|
|
|
|
if (!resolvedService || typeof resolvedService.getQueuedPointers !== "function") {
|
|
return [];
|
|
}
|
|
|
|
let pointers = [];
|
|
try {
|
|
pointers = await Promise.resolve(
|
|
resolvedService.getQueuedPointers(actor || undefined)
|
|
);
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-source] Failed to load queued pointers",
|
|
error
|
|
);
|
|
pointers = [];
|
|
}
|
|
|
|
const results = [];
|
|
const hook = context?.hooks?.watchHistory;
|
|
const resolveVideoHook =
|
|
isPlainObject(hook) && typeof hook.resolveVideo === "function"
|
|
? hook.resolveVideo
|
|
: null;
|
|
|
|
for (const pointer of Array.isArray(pointers) ? pointers : []) {
|
|
const dto = {
|
|
video: null,
|
|
pointer,
|
|
metadata: {
|
|
source: "watch-history",
|
|
actor: actor || null,
|
|
},
|
|
};
|
|
|
|
if (pointer && isPlainObject(pointer) && pointer.video) {
|
|
dto.video = pointer.video;
|
|
}
|
|
|
|
if (!dto.video && resolveVideoHook) {
|
|
try {
|
|
const resolvedVideo = await resolveVideoHook(pointer, context);
|
|
if (resolvedVideo) {
|
|
dto.video = resolvedVideo;
|
|
}
|
|
} catch (error) {
|
|
context?.log?.(
|
|
"[watch-history-source] resolveVideo hook threw",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
results.push(dto);
|
|
}
|
|
|
|
return results;
|
|
};
|
|
}
|