mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Preserve autoplay preference on fallback; reload open comment thread after login
### 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)
This commit is contained in:
1 parent
d8e5113efd
commit
f0a3ede3e5
7 files changed
+241
-23
No files matched your search
@@ -21,6 +21,7 @@ export {
|
||||
} from "./sorters.js";
|
||||
export {
|
||||
createActiveNostrSource,
|
||||
createWhitelistedAuthorsNostrSource,
|
||||
createSubscriptionAuthorsSource,
|
||||
createWatchHistoryPointerSource,
|
||||
} from "./sources.js";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// js/feedEngine/mostZappedFeed.js
|
||||
//
|
||||
// The "Most Zapped" tab (#47): the recently-added (active) source ranked by
|
||||
// ZAP TOTAL (sats from kind-9735 receipts). Mirrors trendingFeed.js — lives
|
||||
// outside feedCoordinator.js (size cap) and is wired in via thin delegations.
|
||||
// The "Most Zapped" tab (#47): the catalog of approved creators ranked by
|
||||
// ZAP TOTAL (sats from kind-9735 receipts). It is intentionally scoped to the
|
||||
// creator whitelist for discovery, but still runs the ordinary moderation
|
||||
// stages — whitelisting does not waive Web-of-Trust handling.
|
||||
// The metric getter uses requestVideoZapTotal, which returns the cached total
|
||||
// AND schedules a batched receipt fetch for unknown pointers; the Most Zapped
|
||||
// view re-runs the feed on zapTotals' change signal so the order settles as
|
||||
@@ -11,7 +12,7 @@
|
||||
import { FEED_TYPES } from "../constants.js";
|
||||
import { requestVideoZapTotal } from "../zapTotals.js";
|
||||
import {
|
||||
createActiveNostrSource,
|
||||
createWhitelistedAuthorsNostrSource,
|
||||
createBlacklistFilterStage,
|
||||
createDedupeByRootStage,
|
||||
createModerationStage,
|
||||
@@ -43,6 +44,19 @@ function makeGetZapTotal(app) {
|
||||
};
|
||||
}
|
||||
|
||||
function getWhitelistedAuthors(app) {
|
||||
const accessControl = app?.nostrService?.accessControl;
|
||||
if (typeof accessControl?.getWhitelistPubkeys !== "function") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const authors = accessControl.getWhitelistPubkeys();
|
||||
return Array.isArray(authors) ? authors : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMostZappedFeedRuntime(app) {
|
||||
const blacklist =
|
||||
app.blacklistedEventIds instanceof Set
|
||||
@@ -59,6 +73,7 @@ export function buildMostZappedFeedRuntime(app) {
|
||||
? { ...moderationThresholds }
|
||||
: undefined,
|
||||
getZapTotal: makeGetZapTotal(app),
|
||||
whitelistedAuthors: getWhitelistedAuthors(app),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +109,7 @@ export function registerMostZappedFeed(app) {
|
||||
|
||||
try {
|
||||
return app.feedEngine.registerFeed(FEED_TYPES.MOST_ZAPPED, {
|
||||
source: createActiveNostrSource({ service: app.nostrService }),
|
||||
source: createWhitelistedAuthorsNostrSource({ service: app.nostrService }),
|
||||
stages: [
|
||||
createBlacklistFilterStage({
|
||||
shouldIncludeVideo: (video, options) =>
|
||||
|
||||
@@ -62,6 +62,89 @@ export function createActiveNostrSource({ service } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ function closeLoginModal() {
|
||||
}
|
||||
}
|
||||
|
||||
function isHtmlElement(value, documentRef = document) {
|
||||
const HTMLElementConstructor =
|
||||
documentRef?.defaultView?.HTMLElement || globalThis.HTMLElement;
|
||||
return typeof HTMLElementConstructor === "function"
|
||||
? value instanceof HTMLElementConstructor
|
||||
: Boolean(value && value.nodeType === 1);
|
||||
}
|
||||
|
||||
// Cheap insurance against wireBitloginLogin() ever running twice for the same
|
||||
// element (e.g. a future bootstrap change that calls it more than once): the
|
||||
// same <bitlogin-auth> instance should only ever get one "bitlogin-login"
|
||||
@@ -82,12 +90,12 @@ function attachWidget(app, widget) {
|
||||
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
|
||||
// leave the permanent, statically-declared markup in place but never load
|
||||
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
|
||||
if (mount instanceof HTMLElement) {
|
||||
if (isHtmlElement(mount)) {
|
||||
mount.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mount instanceof HTMLElement) {
|
||||
if (isHtmlElement(mount)) {
|
||||
mount.hidden = false;
|
||||
}
|
||||
|
||||
@@ -162,22 +170,29 @@ function attachWidget(app, widget) {
|
||||
* @param {{ authService: { requestLogin: Function } }} app
|
||||
*/
|
||||
export function wireBitloginLogin(app) {
|
||||
if (!app?.authService) {
|
||||
if (!app?.authService || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.getElementById("bitloginWidget");
|
||||
if (existing instanceof HTMLElement) {
|
||||
const documentRef = document;
|
||||
const existing = documentRef.getElementById("bitloginWidget");
|
||||
if (isHtmlElement(existing, documentRef)) {
|
||||
attachWidget(app, existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const widget = document.getElementById("bitloginWidget");
|
||||
if (widget instanceof HTMLElement) {
|
||||
const Observer =
|
||||
documentRef.defaultView?.MutationObserver || globalThis.MutationObserver;
|
||||
if (typeof Observer !== "function" || !documentRef.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new Observer(() => {
|
||||
const widget = documentRef.getElementById("bitloginWidget");
|
||||
if (isHtmlElement(widget, documentRef)) {
|
||||
observer.disconnect();
|
||||
attachWidget(app, widget);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(documentRef.body, { childList: true, subtree: true });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveVerifiedBitloginPubkey } from "../js/ui/bitloginModalIntegration.js";
|
||||
import {
|
||||
resolveVerifiedBitloginPubkey,
|
||||
wireBitloginLogin,
|
||||
} from "../js/ui/bitloginModalIntegration.js";
|
||||
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
@@ -19,3 +22,22 @@ test("BitLogin rejects malformed claims and widgets without a worker-backed publ
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({}, PUBKEY), "");
|
||||
assert.equal(await resolveVerifiedBitloginPubkey({ getPublicKey: async () => PUBKEY }, "npub1nope"), "");
|
||||
});
|
||||
|
||||
test("BitLogin bootstrap safely defers when MutationObserver is unavailable", () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalObserver = globalThis.MutationObserver;
|
||||
globalThis.document = {
|
||||
body: {},
|
||||
defaultView: {},
|
||||
getElementById: () => null,
|
||||
};
|
||||
try {
|
||||
delete globalThis.MutationObserver;
|
||||
assert.doesNotThrow(() => wireBitloginLogin({ authService: {} }));
|
||||
} finally {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
if (originalObserver === undefined) delete globalThis.MutationObserver;
|
||||
else globalThis.MutationObserver = originalObserver;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createWhitelistedAuthorsNostrSource } from "../js/feedEngine/sources.js";
|
||||
|
||||
test("Most Zapped discovery fetches only whitelisted creators and caches the catalog", async () => {
|
||||
const calls = [];
|
||||
const approved = "a".repeat(64);
|
||||
const unapproved = "b".repeat(64);
|
||||
let now = 1_000;
|
||||
const source = createWhitelistedAuthorsNostrSource({
|
||||
now: () => now,
|
||||
service: {
|
||||
async fetchVideosByAuthors(authors) {
|
||||
calls.push(authors);
|
||||
return [
|
||||
{ id: "approved", pubkey: approved, created_at: 20 },
|
||||
{ id: "unapproved", pubkey: unapproved, created_at: 30 },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = { whitelistedAuthors: [approved.toUpperCase()] };
|
||||
const first = await source({ runtime });
|
||||
const rerank = await source({ runtime });
|
||||
|
||||
assert.deepEqual(calls, [[approved]], "relay lookup is author-scoped once");
|
||||
assert.deepEqual(first.map((entry) => entry.video.id), ["approved"]);
|
||||
assert.deepEqual(rerank.map((entry) => entry.video.id), ["approved"]);
|
||||
|
||||
now += 2 * 60 * 1000;
|
||||
await source({ runtime });
|
||||
assert.equal(calls.length, 2, "catalog refreshes after its bounded TTL");
|
||||
});
|
||||
|
||||
test("Most Zapped discovery is empty without approved creators and never queries globally", async () => {
|
||||
let calls = 0;
|
||||
const source = createWhitelistedAuthorsNostrSource({
|
||||
service: {
|
||||
async fetchVideosByAuthors() {
|
||||
calls += 1;
|
||||
return [{ id: "must-not-leak", pubkey: "c".repeat(64) }];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await source({ runtime: { whitelistedAuthors: [] } });
|
||||
assert.deepEqual(result, []);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("Most Zapped never leaks a removed creator while an earlier lookup is in flight", async () => {
|
||||
const removed = "d".repeat(64);
|
||||
const remaining = "e".repeat(64);
|
||||
let resolveFetch;
|
||||
const source = createWhitelistedAuthorsNostrSource({
|
||||
service: {
|
||||
fetchVideosByAuthors() {
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = source({ runtime: { whitelistedAuthors: [removed] } });
|
||||
const afterRemoval = source({ runtime: { whitelistedAuthors: [remaining] } });
|
||||
resolveFetch([{ id: "removed-video", pubkey: removed }]);
|
||||
|
||||
assert.deepEqual((await first).map((entry) => entry.video.id), ["removed-video"]);
|
||||
assert.deepEqual(await afterRemoval, []);
|
||||
});
|
||||
@@ -615,7 +615,7 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
handled).
|
||||
|
||||
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
|
||||
- [ ] **NEXT (planned, not started): bitvid-native zap tally.** Full spec +
|
||||
- [x] **DONE 2026-07-26: bitvid-native zap tally.** Full spec +
|
||||
ordered task checklist in `docs/zap-tally-plan.md`. Problem: NIP-57 zap
|
||||
receipts (9735) are published by the RECIPIENT's LNURL server, and
|
||||
custodial wallets (Strike) don't — so paid zaps leave no on-relay record
|
||||
@@ -624,12 +624,13 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
the preimage→payment_hash + description_hash→zapRequest binding, reusing
|
||||
the existing bolt11 decode in `zapReceiptValidator.js`), counted alongside
|
||||
real 9735s (deduped by payment_hash, 9735-preferred). Behind
|
||||
`FEATURE_ZAP_TALLY` (default off). Plan also covers a
|
||||
`FEATURE_ZAP_TALLY` (default on). Plan also covers a
|
||||
SECOND orange zaps-over-time line on the per-video Popularity chart with a
|
||||
legend (red=Views/orange=Zaps) and visible date labels (docs §5.9). See the
|
||||
plan for the file-by-file changes. Current state (commit `cbc62306`): 9735 counting + optimistic
|
||||
bump + durable local ledger (`bitvid:sentZaps:v1`) already shipped; the
|
||||
tally is the missing global/cross-user source.
|
||||
plan for the file-by-file changes. Current state: kind-9735 counting,
|
||||
optimistic bump + durable local ledger (`bitvid:sentZaps:v1`), and the
|
||||
verified tally source are shipped; receipts and tallies are deduped by
|
||||
payment hash.
|
||||
- [x] **DONE 2026-07-03.** Full Trending-pattern clone ranked by SATS:
|
||||
- `js/zapTotals.js` — per-pointer kind-9735 aggregation. The feed's
|
||||
getZapTotal reads the cached total AND schedules a BATCHED one-shot
|
||||
@@ -647,18 +648,26 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
link, viewManager registry, feedCoordinator case/loader,
|
||||
`FEED_TYPES.MOST_ZAPPED`, `FEATURE_MOST_ZAPPED_FEED` flag (default
|
||||
on; off hides the tab like Trending's flag).
|
||||
- **Discovery hardened 2026-07-26:** fetches the bounded catalog for
|
||||
creator-whitelist pubkeys only (never an unscoped relay query), then
|
||||
ranks those videos by zaps. The normal blacklist and Web-of-Trust
|
||||
stages still run; whitelist membership is not a moderation override.
|
||||
The catalog is briefly cached so zap-total re-ranks do not repeatedly
|
||||
scan relays, and an in-flight result is re-filtered after whitelist
|
||||
changes so removed creators cannot briefly appear.
|
||||
Tests: `tests/zap-totals.test.mjs` (5). Lint + build green.
|
||||
- [x] **Follow-up DONE 2026-07-03 — per-card zap badges.** Video cards now show
|
||||
the zap total in bitcoin ORANGE (new `--color-zap` token, both themes),
|
||||
RIGHT-ALIGNED in the engagement row (views/comments stay left; CSS
|
||||
`margin-inline-start: auto` on `.video-card__zaps`). Hidden until a
|
||||
nonzero total is known (no "0 sats" noise). Binder
|
||||
`margin-inline-start: auto` on `.video-card__zaps`). The badge is visible
|
||||
from bind (showing `0 sats` until receipts arrive). Binder
|
||||
(`js/ui/views/videoCardZapTotals.js`, VideoListView file-size budget)
|
||||
fills `[data-zap-total]` from the shared zapTotals cache — bind() also
|
||||
schedules the batched receipt fetch, and one change listener updates
|
||||
every bound card as batches land. Test:
|
||||
`tests/video-card-zap-totals.test.mjs` (drives the REAL store).
|
||||
- [ ] **VERIFY on unstable:** open Most Zapped → zapped videos rise as receipts
|
||||
- [ ] **VERIFY on unstable:** open Most Zapped → only whitelisted creators are
|
||||
discovered; zapped videos rise as receipts
|
||||
load (unzapped feed reads as Recent at first) and their cards show the
|
||||
orange right-aligned sats badge; unzapped cards show no badge; zap a
|
||||
video → within ~2min of tab re-entry its rank + badge reflect the new
|
||||
|
||||
Reference in new issue
Block a user