[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<videoRootId,
  {mirrored, kinds, duplicate}>, 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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-29 21:45:23 -04:00
1 parent 26d67d59b2
commit eb4d071203
4 files changed
+235 -7

No files matched your search

+39 -1
View File
@@ -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<videoRootId,
// { mirrored, kinds, duplicate }>. 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();
+57 -1
View File
@@ -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) {
+127
View File
@@ -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("<!doctype html><html><body></body></html>");
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);
});
+12 -5
View File
@@ -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<videoRootId, {mirrored, kinds,
duplicate}>` (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.