mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(liveness): config-driven card visibility policy + probe prefetch (#5)
Makes the card liveness-visibility behavior switchable so it can be A/B feel-tested, and probes ahead of the scroll for a snappier result. - CARD_LIVENESS_POLICY (config/instance-config.js): "show-pending" (default, unchanged) | "hide-foreign" (foreign/ingested cards hidden until a source verifies; native cards stay show-pending) | "hide-all". Owner's own cards are always visible. Flip live for testing without a rebuild: window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" then refresh/scroll. Implemented via a data-foreign provenance flag on the card + getCardLivenessPolicy() in cardSourceVisibility.js. - LIVENESS_PROBE_PREFETCH_MARGIN (default 600px): both liveness probes were already viewport-gated (IntersectionObserver, on-screen-first, WebTorrent priority-queued + concurrency-capped); this expands their rootMargin so cards just below the fold verify before the user reaches them. - Tests: three policy scenarios in tests/video-card-source-visibility.test.mjs; also un-broke 3 pre-existing VideoCard tests by adding the missing IntersectionObserver JSDOM stub. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
df99bdb65f
commit
ccbe8d9bb2
10 files changed
+236
-16
No files matched your search
@@ -249,6 +249,33 @@ export const ALLOW_NSFW_CONTENT = false;
|
||||
*/
|
||||
export const FEATURE_NIP71_INGEST = true;
|
||||
|
||||
/**
|
||||
* Card liveness visibility policy — what a non-owner video card does while its
|
||||
* CDN/WebTorrent liveness probes are still running:
|
||||
* - "show-pending" : show the card immediately, hide only if every source is
|
||||
* confirmed dead (current behavior; can briefly flash a
|
||||
* dead foreign card before the probe hides it).
|
||||
* - "hide-foreign" : foreign/ingested cards stay hidden until a source is
|
||||
* confirmed playable; bitvid-native/own cards show pending.
|
||||
* - "hide-all" : every non-owner card stays hidden until confirmed playable.
|
||||
* Owner's own uploads are always visible regardless of policy.
|
||||
*
|
||||
* Can be flipped live for A/B feel-testing without a rebuild by setting
|
||||
* `window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign"` in the console (then
|
||||
* refresh / scroll the grid). Invalid values fall back to "show-pending".
|
||||
*/
|
||||
export const CARD_LIVENESS_POLICY = "show-pending";
|
||||
|
||||
/**
|
||||
* IntersectionObserver `rootMargin` for the card liveness probes (URL + WebTorrent).
|
||||
* "0px" probes a card only once it touches the viewport; a positive margin (e.g.
|
||||
* "600px") probes cards that distance *below* the fold so they're verified before
|
||||
* the user scrolls to them — makes hide-until-verified feel instant. Probes stay
|
||||
* viewport-gated + concurrency-capped, so a larger margin only reorders work.
|
||||
* Override live with `window.__BITVID_LIVENESS_PREFETCH_MARGIN__`.
|
||||
*/
|
||||
export const LIVENESS_PROBE_PREFETCH_MARGIN = "600px";
|
||||
|
||||
/**
|
||||
* Emergency fallback accounts for seeding the moderation graph.
|
||||
*
|
||||
|
||||
@@ -57,11 +57,14 @@ but a WebTorrent source is live, keep the card.
|
||||
concurrency (it's cheap vs WebTorrent swarm joins).
|
||||
- WebTorrent `PROBE_TIMEOUT_MS` (20s): keep generous (swarm joins are slow) but
|
||||
ensure it runs in parallel and never blocks the CDN verdict.
|
||||
- DECISION NEEDED: **show-pending (current) vs hide-until-verified.** Showing
|
||||
pending means a brief flash of soon-to-be-hidden dead cards; hide-until-verified
|
||||
means good cards pop in after the probe. Recommend: keep show-pending for cards
|
||||
with a *bitvid-native/whitelisted* source (fast/trusted), but **hide-until-verified
|
||||
for foreign/ingested** videos (so unplayable strangers never flash in).
|
||||
- DECISION (2026-06-24): made **config-driven for live A/B testing** instead of
|
||||
hard-coding one policy. `CARD_LIVENESS_POLICY` =
|
||||
`show-pending` (default) | `hide-foreign` | `hide-all`, flippable live via
|
||||
`window.__BITVID_CARD_LIVENESS_POLICY__`. Recommendation stands: `hide-foreign`
|
||||
(native = show-pending, foreign/ingested = hidden-until-verified). Also added a
|
||||
configurable probe prefetch margin (`LIVENESS_PROBE_PREFETCH_MARGIN`, default
|
||||
600px) so cards verify before they scroll into view. Pick the final default
|
||||
after feel-testing.
|
||||
|
||||
## Reuse map
|
||||
- `js/ui/urlHealthController.js` (probeUrl / probeUrlWithVideoElement) — make
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
ALLOW_NSFW_CONTENT,
|
||||
FEATURE_NIP71_INGEST,
|
||||
CARD_LIVENESS_POLICY,
|
||||
LIVENESS_PROBE_PREFETCH_MARGIN,
|
||||
DEFAULT_TRUST_SEED_NPUBS,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
@@ -119,6 +121,8 @@ export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
|
||||
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
|
||||
export { ALLOW_NSFW_CONTENT };
|
||||
export { FEATURE_NIP71_INGEST };
|
||||
export { CARD_LIVENESS_POLICY };
|
||||
export { LIVENESS_PROBE_PREFETCH_MARGIN };
|
||||
export { DEFAULT_TRUST_SEED_NPUBS };
|
||||
export { DEFAULT_BLUR_THRESHOLD };
|
||||
export { DEFAULT_AUTOPLAY_BLOCK_THRESHOLD };
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
DEFAULT_PLAYBACK_SOURCE,
|
||||
DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
|
||||
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
|
||||
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
|
||||
IS_DEV_MODE,
|
||||
} from "./config.js";
|
||||
|
||||
@@ -470,6 +472,64 @@ export function setUrlFirstEnabled(next) {
|
||||
return URL_FIRST_ENABLED;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Card liveness visibility policy (see config/instance-config.js). Resolved on
|
||||
// every read so it can be flipped live for A/B feel-testing via the console:
|
||||
// window.__BITVID_CARD_LIVENESS_POLICY__ = "hide-foreign" // then refresh/scroll
|
||||
// -----------------------------------------------------------------------------
|
||||
export const CARD_LIVENESS_POLICIES = Object.freeze([
|
||||
"show-pending",
|
||||
"hide-foreign",
|
||||
"hide-all",
|
||||
]);
|
||||
|
||||
function normalizeLivenessPolicy(value) {
|
||||
return typeof value === "string" && CARD_LIVENESS_POLICIES.includes(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
let cardLivenessPolicy =
|
||||
normalizeLivenessPolicy(CONFIG_CARD_LIVENESS_POLICY) || "show-pending";
|
||||
|
||||
export function getCardLivenessPolicy() {
|
||||
const override =
|
||||
typeof globalThis !== "undefined"
|
||||
? normalizeLivenessPolicy(globalThis.__BITVID_CARD_LIVENESS_POLICY__)
|
||||
: null;
|
||||
return override || cardLivenessPolicy;
|
||||
}
|
||||
|
||||
export function setCardLivenessPolicy(next) {
|
||||
const normalized = normalizeLivenessPolicy(next);
|
||||
if (normalized) {
|
||||
cardLivenessPolicy = normalized;
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.__BITVID_CARD_LIVENESS_POLICY__ = normalized;
|
||||
}
|
||||
}
|
||||
return cardLivenessPolicy;
|
||||
}
|
||||
|
||||
// IntersectionObserver rootMargin for the liveness probes — lets cards just below
|
||||
// the fold probe ahead of the scroll. Live-overridable for tuning.
|
||||
const DEFAULT_LIVENESS_PREFETCH_MARGIN =
|
||||
typeof CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN === "string" &&
|
||||
CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN.trim()
|
||||
? CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN.trim()
|
||||
: "0px";
|
||||
|
||||
export function getLivenessProbePrefetchMargin() {
|
||||
const override =
|
||||
typeof globalThis !== "undefined"
|
||||
? globalThis.__BITVID_LIVENESS_PREFETCH_MARGIN__
|
||||
: null;
|
||||
if (typeof override === "string" && override.trim()) {
|
||||
return override.trim();
|
||||
}
|
||||
return DEFAULT_LIVENESS_PREFETCH_MARGIN;
|
||||
}
|
||||
|
||||
export function setWssTrackers(next) {
|
||||
runtimeFlags.WSS_TRACKERS = next;
|
||||
return WSS_TRACKERS;
|
||||
|
||||
+8
-2
@@ -13,7 +13,10 @@ import {
|
||||
getViewportCenter,
|
||||
PRIORITY_BASELINE,
|
||||
} from "./gridHealthLogic.js";
|
||||
import { FIVE_MINUTES_MS } from "./constants.js";
|
||||
import {
|
||||
FIVE_MINUTES_MS,
|
||||
getLivenessProbePrefetchMargin,
|
||||
} from "./constants.js";
|
||||
|
||||
const badgeUpdateListeners = new Set();
|
||||
|
||||
@@ -34,7 +37,10 @@ function notifyBadgeUpdate(payload) {
|
||||
});
|
||||
}
|
||||
|
||||
const ROOT_MARGIN = "200px 0px";
|
||||
// Configurable prefetch margin so cards probe ahead of the scroll (WebTorrent
|
||||
// probes stay concurrency-capped + priority-ordered, so a larger margin only
|
||||
// reorders work). Falls back to the existing 200px vertical prefetch.
|
||||
const ROOT_MARGIN = getLivenessProbePrefetchMargin() || "200px 0px";
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
|
||||
@@ -2625,6 +2625,12 @@ export class VideoCard {
|
||||
if (this.video.pubkey) {
|
||||
this.root.dataset.ownerPubkey = this.video.pubkey;
|
||||
}
|
||||
// Provenance flag for the liveness visibility policy: foreign/ingested videos
|
||||
// (pulled in from other Nostr apps) can be hidden-until-verified separately
|
||||
// from bitvid-native uploads. See cardSourceVisibility + CARD_LIVENESS_POLICY.
|
||||
const isForeign =
|
||||
this.video?.foreign === true || this.video?.source === "nip71-ingest";
|
||||
this.root.dataset.foreign = isForeign ? "true" : "false";
|
||||
}
|
||||
|
||||
applySourceDatasets() {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createCardObserver } from "./dom/cardObserver.js";
|
||||
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
import { getLivenessProbePrefetchMargin } from "./constants.js";
|
||||
|
||||
const ROOT_MARGIN = "0px";
|
||||
// Prefetch margin lets cards just below the fold start probing before they scroll
|
||||
// into view, so they're verified by the time the user reaches them.
|
||||
const ROOT_MARGIN = getLivenessProbePrefetchMargin();
|
||||
const THRESHOLD = 0.25;
|
||||
|
||||
const urlCardObserver = createCardObserver({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getCardLivenessPolicy } from "../constants.js";
|
||||
|
||||
function resolveCardElement(cardLike) {
|
||||
if (!cardLike) {
|
||||
return null;
|
||||
@@ -65,11 +67,22 @@ export function updateVideoCardSourceVisibility(cardLike) {
|
||||
const cdnHealthy = cdnState === "healthy";
|
||||
const streamHealthy = streamState === "healthy";
|
||||
|
||||
const shouldHide =
|
||||
!cdnHealthy &&
|
||||
!streamHealthy &&
|
||||
!cdnPending &&
|
||||
!streamPending;
|
||||
// Liveness visibility policy (config/instance-config.js → CARD_LIVENESS_POLICY):
|
||||
// show-pending : show now, hide only once every source is confirmed dead.
|
||||
// hide-foreign : foreign/ingested cards stay hidden until a source is proven
|
||||
// playable; native cards keep show-pending.
|
||||
// hide-all : every non-owner card stays hidden until proven playable.
|
||||
const policy = getCardLivenessPolicy();
|
||||
const isForeign = card.dataset.foreign === "true";
|
||||
const hideUntilVerified =
|
||||
policy === "hide-all" || (policy === "hide-foreign" && isForeign);
|
||||
|
||||
const shouldHide = hideUntilVerified
|
||||
? // Hidden until at least one source proves playable (covers the pending
|
||||
// window too, so a dead foreign stranger never flashes in).
|
||||
!cdnHealthy && !streamHealthy
|
||||
: // Default: only hide once every source has come back dead.
|
||||
!cdnHealthy && !streamHealthy && !cdnPending && !streamPending;
|
||||
|
||||
if (shouldHide) {
|
||||
if (!card.hidden) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js";
|
||||
import { setCardLivenessPolicy } from "../js/constants.js";
|
||||
import { VideoCard } from "../js/ui/components/VideoCard.js";
|
||||
|
||||
function setupDom(t) {
|
||||
@@ -22,6 +23,7 @@ function setupDom(t) {
|
||||
"MouseEvent",
|
||||
"CustomEvent",
|
||||
"ResizeObserver",
|
||||
"IntersectionObserver",
|
||||
"CSSStyleSheet",
|
||||
"requestAnimationFrame",
|
||||
"cancelAnimationFrame",
|
||||
@@ -40,6 +42,21 @@ function setupDom(t) {
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
} else if (
|
||||
key === "IntersectionObserver" &&
|
||||
typeof window[key] !== "function"
|
||||
) {
|
||||
// JSDOM doesn't implement IntersectionObserver; VideoCard.observeViewport
|
||||
// constructs one at build time. A no-op stub is enough for these tests.
|
||||
window.IntersectionObserver = class {
|
||||
constructor() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis[key] = window[key];
|
||||
});
|
||||
@@ -140,6 +157,71 @@ test("VideoCard hides cards without playable sources until a healthy CDN update
|
||||
assert.equal(root.dataset.sourceVisibility, "visible", "visibility dataset should reflect the restored state");
|
||||
});
|
||||
|
||||
function makeCard(document, { foreign = "false", owner = "false", url = "checking", stream = "checking" } = {}) {
|
||||
const card = document.createElement("article");
|
||||
card.classList.add("card");
|
||||
card.dataset.ownerIsViewer = owner;
|
||||
card.dataset.foreign = foreign;
|
||||
card.dataset.urlHealthState = url;
|
||||
card.dataset.streamHealthState = stream;
|
||||
document.body.appendChild(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
test("CARD_LIVENESS_POLICY=show-pending keeps a pending foreign card visible (current behavior)", (t) => {
|
||||
const { document } = setupDom(t);
|
||||
setCardLivenessPolicy("show-pending");
|
||||
t.after(() => setCardLivenessPolicy("show-pending"));
|
||||
|
||||
// Probes still running (checking) → the card shows; it only hides once every
|
||||
// source is confirmed dead.
|
||||
const card = makeCard(document, { foreign: "true", url: "checking", stream: "checking" });
|
||||
updateVideoCardSourceVisibility(card);
|
||||
assert.equal(card.hidden, false, "pending foreign card is visible under show-pending");
|
||||
|
||||
card.dataset.urlHealthState = "offline";
|
||||
card.dataset.streamHealthState = "unhealthy";
|
||||
updateVideoCardSourceVisibility(card);
|
||||
assert.equal(card.hidden, true, "confirmed-dead card hides");
|
||||
});
|
||||
|
||||
test("CARD_LIVENESS_POLICY=hide-foreign hides pending FOREIGN cards but shows pending NATIVE cards", (t) => {
|
||||
const { document } = setupDom(t);
|
||||
setCardLivenessPolicy("hide-foreign");
|
||||
t.after(() => setCardLivenessPolicy("show-pending"));
|
||||
|
||||
const foreignCard = makeCard(document, { foreign: "true", url: "checking", stream: "checking" });
|
||||
updateVideoCardSourceVisibility(foreignCard);
|
||||
assert.equal(foreignCard.hidden, true, "foreign card stays hidden until a source verifies");
|
||||
|
||||
const nativeCard = makeCard(document, { foreign: "false", url: "checking", stream: "checking" });
|
||||
updateVideoCardSourceVisibility(nativeCard);
|
||||
assert.equal(nativeCard.hidden, false, "native card keeps show-pending behavior");
|
||||
|
||||
// A foreign card becomes visible the moment any source proves playable.
|
||||
foreignCard.dataset.streamHealthState = "healthy";
|
||||
updateVideoCardSourceVisibility(foreignCard);
|
||||
assert.equal(foreignCard.hidden, false, "verified foreign card appears");
|
||||
});
|
||||
|
||||
test("CARD_LIVENESS_POLICY=hide-all hides every pending non-owner card but never the owner's", (t) => {
|
||||
const { document } = setupDom(t);
|
||||
setCardLivenessPolicy("hide-all");
|
||||
t.after(() => setCardLivenessPolicy("show-pending"));
|
||||
|
||||
const nativeCard = makeCard(document, { foreign: "false", url: "checking", stream: "checking" });
|
||||
updateVideoCardSourceVisibility(nativeCard);
|
||||
assert.equal(nativeCard.hidden, true, "even a native card hides until verified under hide-all");
|
||||
|
||||
const ownerCard = makeCard(document, { owner: "true", url: "checking", stream: "checking" });
|
||||
updateVideoCardSourceVisibility(ownerCard);
|
||||
assert.equal(ownerCard.hidden, false, "the owner's own card is always visible");
|
||||
|
||||
nativeCard.dataset.urlHealthState = "healthy";
|
||||
updateVideoCardSourceVisibility(nativeCard);
|
||||
assert.equal(nativeCard.hidden, false, "card appears once a source verifies");
|
||||
});
|
||||
|
||||
test("VideoCard.closeMoreMenu only restores focus when the trigger was expanded", (t) => {
|
||||
const { document } = setupDom(t);
|
||||
|
||||
|
||||
@@ -303,9 +303,25 @@ un-hides when WebTorrent flips green). The real gaps:
|
||||
playbackService_order suite in CI). ROLLBACK: revert the loop in
|
||||
`playbackService.js` to the single `attemptHostedPlayback()` call + drop the
|
||||
`sources` plumbing.
|
||||
- [ ] **Remaining — hide-until-verified for foreign/ingested** (decision): so
|
||||
unplayable strangers never briefly flash before the probe hides them
|
||||
(bitvid-native stays show-pending). UX policy change in cardSourceVisibility.
|
||||
- [x] **Made the policy config-driven for live A/B testing (2026-06-24).** New
|
||||
`CARD_LIVENESS_POLICY` (config/instance-config.js): `show-pending` (default,
|
||||
unchanged) | `hide-foreign` (foreign/ingested hidden until a source verifies;
|
||||
native stays show-pending) | `hide-all`. Owner's own cards always visible.
|
||||
Flip it live without a rebuild: `window.__BITVID_CARD_LIVENESS_POLICY__ =
|
||||
"hide-foreign"` then refresh/scroll. Implemented via a `data-foreign`
|
||||
provenance flag on the card + `getCardLivenessPolicy()` in
|
||||
`cardSourceVisibility.js`. Tests: three policy scenarios in
|
||||
`tests/video-card-source-visibility.test.mjs` (also un-broke 3 pre-existing
|
||||
VideoCard tests by adding the missing IntersectionObserver JSDOM stub).
|
||||
- [x] **Probe speed — prefetch ahead of scroll (2026-06-24).** Both liveness
|
||||
probes were already viewport-gated (IntersectionObserver, on-screen-first,
|
||||
WebTorrent priority-queued + concurrency-capped). Added a configurable
|
||||
`LIVENESS_PROBE_PREFETCH_MARGIN` (default `600px`) applied to both observers'
|
||||
`rootMargin`, so cards just below the fold verify before the user scrolls to
|
||||
them — makes hide-until-verified feel instant. Override:
|
||||
`window.__BITVID_LIVENESS_PREFETCH_MARGIN__`.
|
||||
- [ ] **DECIDE after live A/B**: pick the default policy that feels best
|
||||
(recommend `hide-foreign`) and set `CARD_LIVENESS_POLICY` in instance-config.
|
||||
|
||||
### 18. Embed button / modal on the video player does not open — FIXED 2026-06-23
|
||||
- [x] **Root cause: event-name mismatch.** `VideoModal.handleEmbedRequest`
|
||||
|
||||
Reference in new issue
Block a user