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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-25 09:13:52 -04:00
1 parent df99bdb65f
commit ccbe8d9bb2
10 files changed
+236 -16

No files matched your search

+27
View File
@@ -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.
*
+8 -5
View File
@@ -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
+4
View File
@@ -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 };
+60
View File
@@ -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
View File
@@ -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();
+6
View File
@@ -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() {
+4 -1
View File
@@ -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({
+18 -5
View File
@@ -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);
+19 -3
View File
@@ -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`