diff --git a/config/instance-config.js b/config/instance-config.js index 6a22e0ef..3f318a5d 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -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. * diff --git a/docs/video-liveness-plan.md b/docs/video-liveness-plan.md index 22b28092..f7dad5b8 100644 --- a/docs/video-liveness-plan.md +++ b/docs/video-liveness-plan.md @@ -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 diff --git a/js/config.js b/js/config.js index d4e20286..5f833253 100644 --- a/js/config.js +++ b/js/config.js @@ -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 }; diff --git a/js/constants.js b/js/constants.js index 2dfc4d5f..86e5e7be 100644 --- a/js/constants.js +++ b/js/constants.js @@ -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; diff --git a/js/gridHealth.js b/js/gridHealth.js index 8b67c7de..a94fbd98 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -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(); diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index dfaff955..8f2e21eb 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -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() { diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index e28806ef..f9e57844 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -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({ diff --git a/js/utils/cardSourceVisibility.js b/js/utils/cardSourceVisibility.js index a3f91b1c..3dba91e0 100644 --- a/js/utils/cardSourceVisibility.js +++ b/js/utils/cardSourceVisibility.js @@ -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) { diff --git a/tests/video-card-source-visibility.test.mjs b/tests/video-card-source-visibility.test.mjs index e56020f4..94959362 100644 --- a/tests/video-card-source-visibility.test.mjs +++ b/tests/video-card-source-visibility.test.mjs @@ -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); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 03772129..ef6469b5 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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`