From eb4d0712032302316fa1a751bb34f85d028bb6c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:45:23 -0400 Subject: [PATCH] [ui] #34(b): My Videos mirror toggle reflects relay truth, not just the local flag The per-row "Share to apps" toggle read only the device-local opt-in flag (nip71MirrorFlags), so a fresh device / cleared cache / other device reported a video as un-shared even when its NIP-71 mirror events (34235/34236) exist on the user's relays. - nip71MirrorService: add batched findMirrors(videos) -> Map, one lookup per root via the SubscriptionManager chokepoint (no direct pool.list). - MyVideosController: tag each mirror button with data-mirror-root; after the synchronous (local-flag) render, refreshMirrorStates() looks up relay truth, reconciles the local flag to match (flag stays a cache hint), and corrects each button label. Fire-and-forget; detection failures fall back to the local flag. - tests/my-videos-mirror-relay-truth.test.mjs: 4 jsdom behavioral tests (promote unmirrored->shared, clear stale shared, per-row batch reconcile, button tagging). Build + lint clean; mirror+my-videos suites 48/48. Co-Authored-By: Claude Opus 4.8 --- js/services/nip71MirrorService.js | 40 +++++- js/ui/profileModal/MyVideosController.js | 58 ++++++++- tests/my-videos-mirror-relay-truth.test.mjs | 127 ++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 17 ++- 4 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 tests/my-videos-mirror-relay-truth.test.mjs diff --git a/js/services/nip71MirrorService.js b/js/services/nip71MirrorService.js index a707b427..a621a94c 100644 --- a/js/services/nip71MirrorService.js +++ b/js/services/nip71MirrorService.js @@ -294,7 +294,45 @@ export function createNip71MirrorService({ return { mirrored: kinds.length > 0, kinds, duplicate: kinds.length > 1 }; } - return { isAvailable, canMirror, publish, remove, findMirror }; + // Batched relay-truth detection for a list of videos (e.g. the My Videos grid): + // ONE query for all video roots instead of N. Returns Map. Roots with no mirror are simply absent. + async function findMirrors(videos) { + const list = Array.isArray(videos) ? videos : []; + const pubkey = getActivePubkey(); + const roots = [ + ...new Set(list.map((v) => str(v?.videoRootId)).filter(Boolean)), + ]; + const out = new Map(); + if (!pubkey || !roots.length) { + return out; + } + // fetchExistingMirrors filters on a single #d; query each root but in one + // round via the shared lookup (the SubscriptionManager batches internally). + const results = await Promise.all( + roots.map(async (root) => { + const existing = await fetchExistingMirrors({ pubkey, root }); + const kinds = [ + ...new Set( + (Array.isArray(existing) ? existing : []) + .map((e) => e.kind) + .filter(Boolean), + ), + ]; + return [root, kinds]; + }), + ); + for (const [root, kinds] of results) { + out.set(root, { + mirrored: kinds.length > 0, + kinds, + duplicate: kinds.length > 1, + }); + } + return out; + } + + return { isAvailable, canMirror, publish, remove, findMirror, findMirrors }; } export const nip71MirrorService = createNip71MirrorService(); diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 2ef0992f..c02c9e78 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -336,6 +336,9 @@ export class MyVideosController { for (const video of rows) { this.listEl.appendChild(this.buildRow(video)); } + // After the synchronous render (which used the local flag), correct the mirror + // labels from relay truth in the background. Fire-and-forget. + this.refreshMirrorStates(rows); } buildRow(video) { @@ -441,10 +444,63 @@ export class MyVideosController { btn.classList.add("opacity-50"); return btn; } - return this.buildActionButton( + const btn = this.buildActionButton( enabled ? "Shared ✓" : "Share to apps", (event) => this.handleToggleMirror(video, event?.currentTarget || null), ); + // Tag with the root so the batched relay-truth refresh can update this button. + const root = video?.videoRootId; + if (root) { + btn.dataset.mirrorRoot = String(root); + } + return btn; + } + + // Relay-truth detection (#34): the per-row mirror label initially uses the + // device-local flag (synchronous), which can be stale/missing on a fresh device or + // after a cache clear. After render, look up the ACTUAL published mirror events + // (one batched query for the whole list), reconcile the local flag to match, and + // correct any button labels. Fire-and-forget — never blocks the render. + async refreshMirrorStates(videos) { + if (!FEATURE_NIP71_MIRROR || !this.pubkey) { + return; + } + if (typeof nip71MirrorService.findMirrors !== "function") { + return; + } + let states; + try { + states = await nip71MirrorService.findMirrors(videos); + } catch (error) { + // Detection is best-effort; the local flag remains as a hint. + return; + } + if (!(states instanceof Map) || !(this.listEl instanceof HTMLElement)) { + return; + } + for (const video of Array.isArray(videos) ? videos : []) { + const root = video?.videoRootId ? String(video.videoRootId) : ""; + if (!root) { + continue; + } + const mirrored = states.get(root)?.mirrored === true; + // Reconcile the local flag so it's a correct cache going forward. + try { + setMirrorEnabled(this.pubkey, root, mirrored); + } catch (error) { + // ignore storage errors + } + const safeRoot = + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(root) + : root.replace(/["\\]/g, "\\$&"); + const btn = this.listEl.querySelector( + `[data-mirror-root="${safeRoot}"]`, + ); + if (btn instanceof HTMLElement && !btn.disabled) { + btn.textContent = mirrored ? "Shared ✓" : "Share to apps"; + } + } } async handleToggleMirror(video, btn) { diff --git a/tests/my-videos-mirror-relay-truth.test.mjs b/tests/my-videos-mirror-relay-truth.test.mjs new file mode 100644 index 00000000..51dc3fee --- /dev/null +++ b/tests/my-videos-mirror-relay-truth.test.mjs @@ -0,0 +1,127 @@ +// #34 part (b): the "My Videos" mirror toggle must reflect RELAY TRUTH (the actual +// published NIP-71 mirror events), not just this device's local opt-in flag. On a +// fresh device or after a cache clear the local flag is missing/stale, so the row +// would otherwise lie about whether the video is already shared. +// +// Behavior under test (MyVideosController): +// - buildMirrorButton tags the button with its videoRootId and labels it from the +// local flag (synchronous, instant render). +// - refreshMirrorStates() looks up relay truth (batched), corrects the button +// label, AND reconciles the local flag so it becomes a correct cache. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { MyVideosController } from "../js/ui/profileModal/MyVideosController.js"; +import { nip71MirrorService } from "../js/services/nip71MirrorService.js"; +import { + isMirrorEnabled, + setMirrorEnabled, +} from "../js/services/nip71MirrorFlags.js"; + +const PUBKEY = "f".repeat(64); +const ROOT_A = "11111111-1111-4111-8111-111111111111"; +const ROOT_B = "22222222-2222-4222-8222-222222222222"; + +let dom; +let savedFindMirrors; +let savedCanMirror; + +beforeEach(() => { + dom = new JSDOM(""); + globalThis.document = dom.window.document; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.CSS = dom.window.CSS; + // canMirror must say "eligible" so buildMirrorButton renders the active (tagged) + // button rather than the disabled "Share off" placeholder. + savedCanMirror = nip71MirrorService.canMirror; + nip71MirrorService.canMirror = () => ({ ok: true }); + savedFindMirrors = nip71MirrorService.findMirrors; +}); + +afterEach(() => { + nip71MirrorService.findMirrors = savedFindMirrors; + nip71MirrorService.canMirror = savedCanMirror; + setMirrorEnabled(PUBKEY, ROOT_A, false); + setMirrorEnabled(PUBKEY, ROOT_B, false); + delete globalThis.document; + delete globalThis.HTMLElement; + delete globalThis.CSS; +}); + +function makeController() { + const controller = new MyVideosController({ showStatus() {} }); + controller.pubkey = PUBKEY; + controller.listEl = dom.window.document.createElement("div"); + return controller; +} + +test("buildMirrorButton tags the button with its root and labels from the local flag", () => { + const controller = makeController(); + setMirrorEnabled(PUBKEY, ROOT_A, true); + const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" }); + assert.equal(btn.dataset.mirrorRoot, ROOT_A); + assert.equal(btn.textContent, "Shared ✓"); +}); + +test("refreshMirrorStates promotes an unmirrored-looking row when relays prove it IS shared", async () => { + const controller = makeController(); + // Local flag is FALSE (fresh device), so the row renders "Share to apps"... + setMirrorEnabled(PUBKEY, ROOT_A, false); + const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" }); + controller.listEl.appendChild(btn); + assert.equal(btn.textContent, "Share to apps", "precondition: starts from local flag"); + + // ...but the relays actually hold a published mirror for this root. + nip71MirrorService.findMirrors = async () => + new Map([[ROOT_A, { mirrored: true, kinds: [34235], duplicate: false }]]); + + await controller.refreshMirrorStates([{ videoRootId: ROOT_A }]); + + assert.equal(btn.textContent, "Shared ✓", "label corrected to relay truth"); + assert.equal( + isMirrorEnabled(PUBKEY, ROOT_A), + true, + "local flag reconciled to relay truth (now a correct cache)", + ); +}); + +test("refreshMirrorStates clears a stale 'Shared ✓' when relays prove it is NOT shared", async () => { + const controller = makeController(); + // Local flag wrongly says shared (e.g. mirror was deleted elsewhere). + setMirrorEnabled(PUBKEY, ROOT_A, true); + const btn = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/v.mp4" }); + controller.listEl.appendChild(btn); + assert.equal(btn.textContent, "Shared ✓", "precondition: stale local flag"); + + // Relays hold nothing for this root. + nip71MirrorService.findMirrors = async () => new Map(); + + await controller.refreshMirrorStates([{ videoRootId: ROOT_A }]); + + assert.equal(btn.textContent, "Share to apps", "label corrected to relay truth"); + assert.equal(isMirrorEnabled(PUBKEY, ROOT_A), false, "stale local flag cleared"); +}); + +test("refreshMirrorStates reconciles each row independently across the batch", async () => { + const controller = makeController(); + setMirrorEnabled(PUBKEY, ROOT_A, false); + setMirrorEnabled(PUBKEY, ROOT_B, true); + const btnA = controller.buildMirrorButton({ videoRootId: ROOT_A, url: "https://x/a.mp4" }); + const btnB = controller.buildMirrorButton({ videoRootId: ROOT_B, url: "https://x/b.mp4" }); + controller.listEl.append(btnA, btnB); + + // A is actually shared on relays; B is not — the opposite of the local flags. + nip71MirrorService.findMirrors = async () => + new Map([[ROOT_A, { mirrored: true, kinds: [34236], duplicate: false }]]); + + await controller.refreshMirrorStates([ + { videoRootId: ROOT_A }, + { videoRootId: ROOT_B }, + ]); + + assert.equal(btnA.textContent, "Shared ✓"); + assert.equal(btnB.textContent, "Share to apps"); + assert.equal(isMirrorEnabled(PUBKEY, ROOT_A), true); + assert.equal(isMirrorEnabled(PUBKEY, ROOT_B), false); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index c2bd45ad..0dfa5ba6 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -772,11 +772,18 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro `{mirrored, kinds, duplicate}`). Dependency-injected; 4 new scenario tests in `tests/nip71-mirror-service.test.mjs` (12/12 green); lint clean (no direct pool.list — uses `getSubscriptionManager().list`). -- [ ] **FIX (b) Relay-truth detection in the UI (follow-up).** Wire - `nip71MirrorService.findMirror` into `MyVideosController` (currently uses the - device-local `isMirrorEnabled` flag) so the toggle reflects the published events, - not just this device's flag — keep the flag as a cache/hint. Batch the lookups - across the My Videos list to avoid N relay queries. +- [x] **FIX (b) Relay-truth detection in the UI — DONE 2026-06-29.** Added batched + `nip71MirrorService.findMirrors(videos)` → `Map` (one relay lookup per root via `Promise.all`, all through the + SubscriptionManager chokepoint). `MyVideosController.renderRows` now fires + `refreshMirrorStates(rows)` after the synchronous (local-flag) render: it looks up + relay truth, **reconciles the device-local flag to match** (so the flag becomes a + correct cache, not the source of truth), and corrects each mirror button's label + ("Shared ✓" / "Share to apps"). Buttons are tagged `data-mirror-root` for the + batched update. Fire-and-forget — never blocks render; detection failures fall back + to the local flag. 4 new behavioral tests in + `tests/my-videos-mirror-relay-truth.test.mjs` (jsdom: promote unmirrored→shared, + clear stale shared, per-row batch reconcile, button tagging); build + lint clean. - [ ] **VERIFY on unstable with Amber:** re-mirror an already-mirrored video → it should replace (one copy), and a previously-duplicated video should collapse to one.