mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
26d67d59b2
commit
eb4d071203
4 files changed
+235
-7
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user