diff --git a/js/gridHealth.js b/js/gridHealth.js index a94fbd98..334099d1 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -1,6 +1,9 @@ import { createCardObserver } from "./dom/cardObserver.js"; import { infoHashFromMagnet } from "./magnets.js"; -import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js"; +import { + updateVideoCardSourceVisibility, + cardNeedsEagerLivenessProbe, +} from "./utils/cardSourceVisibility.js"; import { TorrentClient, torrentClient } from "./webtorrent.js"; import { userLogger } from "./utils/logger.js"; import { @@ -532,9 +535,24 @@ const gridCardObserver = createCardObserver({ const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; return isIntersecting && ratio > 0; }, - onCardRegister: ({ card }) => { + onCardRegister: ({ card, state }) => { if (!card.dataset.magnet) { setBadge(card, "unhealthy", { reason: "missing-source" }); + return; + } + // Hide-until-verified cards start hidden, so the viewport observer can never + // fire for them — probe eagerly on register (still concurrency-capped) so + // they can be verified and revealed instead of staying hidden forever. + if ( + cardNeedsEagerLivenessProbe(card) && + state && + state.pendingByCard instanceof WeakMap + ) { + handleCardVisible({ + card, + pendingByCard: state.pendingByCard, + priority: PRIORITY_BASELINE, + }); } }, onCardVisible: ({ card, meta, state }) => { diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index f9e57844..7fbc3ebd 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -2,6 +2,7 @@ import { createCardObserver } from "./dom/cardObserver.js"; import { safeDecodeURIComponent } from "./utils/safeDecode.js"; import { userLogger } from "./utils/logger.js"; import { getLivenessProbePrefetchMargin } from "./constants.js"; +import { cardNeedsEagerLivenessProbe } from "./utils/cardSourceVisibility.js"; // 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. @@ -20,44 +21,58 @@ const urlCardObserver = createCardObserver({ return isIntersecting && ratio > 0; }, createState: () => ({ onCheck: null }), - onCardVisible: ({ card, state }) => { - if (!state || typeof state.onCheck !== "function") { - return; - } - - const badgeEl = card.querySelector("[data-url-health-state]"); - if (!(badgeEl instanceof HTMLElement)) { - return; - } - - const encodedUrl = - card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || ""; - const eventId = - card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || ""; - - if (!encodedUrl || !eventId) { - return; - } - - const currentState = - card.dataset.urlHealthState || badgeEl.dataset.urlHealthState; - if (currentState && currentState !== "checking") { - return; - } - - const url = safeDecodeURIComponent(encodedUrl); - if (!url) { - return; - } - - try { - state.onCheck({ card, badgeEl, url, eventId }); - } catch (err) { - userLogger.warn("[urlHealthObserver] onCheck handler failed", err); + // Hide-until-verified cards start `display:none`, so the IntersectionObserver + // never fires for them — kick the URL probe eagerly on register so they can be + // verified and revealed (the alternative is they stay hidden forever). + onCardRegister: ({ card, state }) => { + if (cardNeedsEagerLivenessProbe(card)) { + // attachUrlHealthBadges sets state.onCheck right AFTER observe() returns, so + // defer one microtask to let that wiring land before we probe. + queueMicrotask(() => triggerUrlCheck(card, state)); } }, + onCardVisible: ({ card, state }) => { + triggerUrlCheck(card, state); + }, }); +function triggerUrlCheck(card, state) { + if (!state || typeof state.onCheck !== "function") { + return; + } + + const badgeEl = card.querySelector("[data-url-health-state]"); + if (!(badgeEl instanceof HTMLElement)) { + return; + } + + const encodedUrl = + card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || ""; + const eventId = + card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || ""; + + if (!encodedUrl || !eventId) { + return; + } + + const currentState = + card.dataset.urlHealthState || badgeEl.dataset.urlHealthState; + if (currentState && currentState !== "checking") { + return; + } + + const url = safeDecodeURIComponent(encodedUrl); + if (!url) { + return; + } + + try { + state.onCheck({ card, badgeEl, url, eventId }); + } catch (err) { + userLogger.warn("[urlHealthObserver] onCheck handler failed", err); + } +} + export function attachUrlHealthBadges(container, onCheck) { if (!(container instanceof HTMLElement)) { return; diff --git a/js/utils/cardSourceVisibility.js b/js/utils/cardSourceVisibility.js index 3dba91e0..bc64fd58 100644 --- a/js/utils/cardSourceVisibility.js +++ b/js/utils/cardSourceVisibility.js @@ -1,5 +1,27 @@ import { getCardLivenessPolicy } from "../constants.js"; +/** + * Whether a card is governed by a hide-until-verified policy (so it starts hidden + * and must be PROBED to ever appear). Such cards can't rely on the viewport + * IntersectionObserver — a `display:none` card never intersects, so the probe + * must be kicked eagerly on register. Owner's own cards are always visible and + * never gated. + */ +export function cardNeedsEagerLivenessProbe(cardLike) { + const card = resolveCardElement(cardLike); + if (!card || card.dataset.ownerIsViewer === "true") { + return false; + } + const policy = getCardLivenessPolicy(); + if (policy === "hide-all") { + return true; + } + if (policy === "hide-foreign") { + return card.dataset.foreign === "true"; + } + return false; +} + function resolveCardElement(cardLike) { if (!cardLike) { return null; diff --git a/tests/video-card-source-visibility.test.mjs b/tests/video-card-source-visibility.test.mjs index 94959362..b33c1d6f 100644 --- a/tests/video-card-source-visibility.test.mjs +++ b/tests/video-card-source-visibility.test.mjs @@ -2,7 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; -import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js"; +import { + updateVideoCardSourceVisibility, + cardNeedsEagerLivenessProbe, +} from "../js/utils/cardSourceVisibility.js"; import { setCardLivenessPolicy } from "../js/constants.js"; import { VideoCard } from "../js/ui/components/VideoCard.js"; @@ -222,6 +225,31 @@ test("CARD_LIVENESS_POLICY=hide-all hides every pending non-owner card but never assert.equal(nativeCard.hidden, false, "card appears once a source verifies"); }); +// Hide-until-verified cards start display:none, so the viewport observer can't +// fire for them — they must be eager-probed. This predicate gates that, and it +// must mirror the visibility policy exactly so the right cards get probed. +test("cardNeedsEagerLivenessProbe gates eager probing to hidden-until-verified cards", (t) => { + const { document } = setupDom(t); + t.after(() => setCardLivenessPolicy("show-pending")); + + const foreign = makeCard(document, { foreign: "true" }); + const native = makeCard(document, { foreign: "false" }); + const owner = makeCard(document, { foreign: "true", owner: "true" }); + + setCardLivenessPolicy("show-pending"); + assert.equal(cardNeedsEagerLivenessProbe(foreign), false, "show-pending never eager-probes"); + assert.equal(cardNeedsEagerLivenessProbe(native), false); + + setCardLivenessPolicy("hide-foreign"); + assert.equal(cardNeedsEagerLivenessProbe(foreign), true, "foreign card is eager-probed"); + assert.equal(cardNeedsEagerLivenessProbe(native), false, "native card stays viewport-gated"); + assert.equal(cardNeedsEagerLivenessProbe(owner), false, "owner card is never gated"); + + setCardLivenessPolicy("hide-all"); + assert.equal(cardNeedsEagerLivenessProbe(native), true, "hide-all eager-probes native too"); + assert.equal(cardNeedsEagerLivenessProbe(owner), false, "owner card is never gated"); +}); + 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 ef6469b5..37744414 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -320,8 +320,17 @@ un-hides when WebTorrent flips green). The real gaps: `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. +- [x] **Deadlock fixed (2026-06-24).** First hide-foreign test made ALL foreign/ + NIP-71 content vanish: a hide-until-verified card starts `display:none`, which + has no layout box, so the IntersectionObserver never fires for it → its + liveness probe never runs → it never gets a healthy verdict → hidden forever. + Fix: `cardNeedsEagerLivenessProbe()` + eager-probe such cards on + `onCardRegister` (fires regardless of visibility) in both `gridHealth` and + `urlHealthObserver`, instead of waiting for an intersection that can't happen. + Still concurrency-capped/priority-ordered; show-pending unchanged. Default is + now `hide-foreign`. +- [ ] **DECIDE after live A/B**: confirm `hide-foreign` feels right (foreign cards + now verify-then-appear); else flip `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`