From e8e2b04de7b3ff98698bb0a0fdb05ad218429c24 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:51:00 -0400 Subject: [PATCH] fix(nip71): keep upload mirrors in sync --- components/profile-modal.html | 2 +- components/upload-modal.html | 4 + js/services/nip71MirrorFlags.js | 20 ++-- js/services/nip71MirrorService.js | 111 +++++++++++++++-------- js/services/nip71MirrorSync.js | 53 ++++++++--- js/services/videoNotePayload.js | 5 + js/ui/components/UploadModal.js | 5 + js/ui/profileModal/MyVideosController.js | 12 ++- tests/nip71-mirror-flags.test.mjs | 8 +- tests/nip71-mirror-service.test.mjs | 67 +++++++++++--- tests/nip71-mirror-sync.test.mjs | 104 ++++++++++++++++++++- tests/video-note-payload.test.mjs | 16 ++++ 12 files changed, 325 insertions(+), 82 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index d66bbd31..4ad4c70e 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1179,7 +1179,7 @@ https://another.blossom.example"> >
For Kids + diff --git a/js/services/nip71MirrorFlags.js b/js/services/nip71MirrorFlags.js index 6331381f..ba9170a1 100644 --- a/js/services/nip71MirrorFlags.js +++ b/js/services/nip71MirrorFlags.js @@ -75,9 +75,10 @@ export function resolveMirrorToggle({ enabled, eligibility } = {}) { return { action: "publish" }; } -// Account-level "auto-share new public videos" preference (per pubkey, off by -// default). When on, newly published eligible public videos are mirrored -// automatically — no per-video opt-in needed. +// Account-level default for new public videos (per pubkey, on by default). A +// missing value means "share" so existing users receive the new default; an +// explicit false is the durable account-level opt-out. An individual upload can +// still opt out without changing this preference. function readAutoShare() { try { if (typeof localStorage === "undefined") { @@ -93,7 +94,7 @@ function readAutoShare() { export function isAutoShareEnabled(pubkey) { const pk = norm(pubkey); - return pk ? readAutoShare()[pk] === true : false; + return pk ? readAutoShare()[pk] !== false : false; } export function setAutoShareEnabled(pubkey, enabled) { @@ -102,11 +103,7 @@ export function setAutoShareEnabled(pubkey, enabled) { return; } const map = readAutoShare(); - if (enabled === true) { - map[pk] = true; - } else { - delete map[pk]; - } + map[pk] = enabled === true; try { if (typeof localStorage !== "undefined") { localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map)); @@ -116,8 +113,9 @@ export function setAutoShareEnabled(pubkey, enabled) { } } -// On publish of a NEW video: mirror it only when auto-share is on AND it's -// eligible (public + hosted URL + allowed). Otherwise do nothing. +// On publish of a NEW video: mirror it only when the account default and the +// per-upload choice permit it, and it is eligible (public + hosted URL + +// allowed). Otherwise do nothing. export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) { if (featureOn !== true || autoShare !== true) { return { action: "none" }; diff --git a/js/services/nip71MirrorService.js b/js/services/nip71MirrorService.js index a621a94c..2cfc0b84 100644 --- a/js/services/nip71MirrorService.js +++ b/js/services/nip71MirrorService.js @@ -72,6 +72,36 @@ export function createNip71MirrorService({ return []; } }, + // One relay query for all roots in the My Videos view. Kept separate from the + // single-root lookup so publish/remove can remain narrowly scoped. + fetchMirrorsByRoots = async ({ pubkey, roots }) => { + const sm = getSubscriptionManager(); + const relays = getWriteRelays() || []; + const uniqueRoots = [...new Set((Array.isArray(roots) ? roots : []).map(str).filter(Boolean))]; + if (!sm || typeof sm.list !== "function" || !relays.length || !uniqueRoots.length) { + return []; + } + try { + const events = await sm.list({ + filters: [ + { + kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND], + authors: [pubkey], + "#d": uniqueRoots, + }, + ], + relays, + }); + return (Array.isArray(events) ? events : []).map((event) => ({ + root: str(event?.tags?.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]), + kind: event?.kind, + created_at: Number(event?.created_at) || 0, + })); + } catch (error) { + userLogger.warn("[nip71Mirror] mirror batch lookup failed:", error); + return []; + } + }, publishEventToRelays = defaultPublishEventToRelays, summarizePublishResults = defaultSummarize, signEvent = async (template) => { @@ -131,26 +161,37 @@ export function createNip71MirrorService({ let forcedShort = options.short; // explicit caller override always wins let staleKind = null; let reusedExistingKind = false; - if (forcedShort === undefined && pubkey && root) { + let newestExisting = null; + if (pubkey && root) { const existing = await fetchExistingMirrors({ pubkey, root }); if (Array.isArray(existing) && existing.length) { const newest = existing.reduce((a, b) => (b.created_at || 0) > (a.created_at || 0) ? b : a, ); - forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND; - reusedExistingKind = true; - const otherKind = forcedShort - ? NIP71_NORMAL_VIDEO_KIND - : NIP71_SHORT_VIDEO_KIND; - if (existing.some((e) => e.kind === otherKind)) { - staleKind = otherKind; + newestExisting = newest; + if (forcedShort === undefined) { + forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND; + reusedExistingKind = true; + const otherKind = forcedShort + ? NIP71_NORMAL_VIDEO_KIND + : NIP71_SHORT_VIDEO_KIND; + if (existing.some((e) => e.kind === otherKind)) { + staleKind = otherKind; + } } } } + // Addressable events only replace an older coordinate. Match the canonical + // edit path: a fast second edit or a clock behind an existing relay event + // must still advance the mirror. + const createdAt = Math.max( + Math.floor(now() / 1000), + (Number(newestExisting?.created_at) || 0) + 1, + ); const built = buildMirrorEvent( { ...video, pubkey }, - { ...options, short: forcedShort, createdAt: Math.floor(now() / 1000) }, + { ...options, short: forcedShort, createdAt }, ); if (!built.ok) { return { ok: false, reason: built.reason }; @@ -181,7 +222,7 @@ export function createNip71MirrorService({ const del = await signEvent({ kind: 5, pubkey, - created_at: Math.floor(now() / 1000), + created_at: createdAt, content: "Removed duplicate NIP-71 mirror", tags: [ ["a", `${staleKind}:${pubkey}:${root}`], @@ -220,7 +261,13 @@ export function createNip71MirrorService({ return { ok: false, error: "invalid" }; } - const createdAt = Math.floor(now() / 1000); + const existing = await fetchExistingMirrors({ pubkey, root }); + const newestCreatedAt = Array.isArray(existing) + ? existing.reduce((max, event) => Math.max(max, Number(event?.created_at) || 0), 0) + : 0; + // NIP-09 only deletes addressable versions at or before the delete event, + // and empty replacements must likewise win their coordinate. + const createdAt = Math.max(Math.floor(now() / 1000), newestCreatedAt + 1); const reason = str(options.reason) || "Removed NIP-71 mirror"; const deleteEvent = { @@ -236,20 +283,17 @@ export function createNip71MirrorService({ ], }; - const width = Number(video?.width); - const height = Number(video?.height); - const portrait = - Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width; - const tombstoneKind = - options.short === true || (options.short !== false && portrait) ? 34236 : 34235; - const tombstone = { - kind: tombstoneKind, + // Empty-replace both coordinates. The NIP-09 delete is the canonical + // teardown, but clients that ignore it must not retain an old mirror when + // a source replacement changed portrait/landscape orientation. + const tombstones = [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND].map((kind) => ({ + kind, pubkey, created_at: createdAt, // No imeta → nothing plays; title kept so the entry reads as removed. tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]], content: "", - }; + })); const relays = getWriteRelays() || []; const pool = getPool(); @@ -259,7 +303,7 @@ export function createNip71MirrorService({ let publishedOk = 0; let total = 0; - for (const template of [deleteEvent, tombstone]) { + for (const template of [deleteEvent, ...tombstones]) { total += 1; let signed; try { @@ -307,22 +351,15 @@ export function createNip71MirrorService({ 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) { + const events = await fetchMirrorsByRoots({ pubkey, roots }); + const kindsByRoot = new Map(roots.map((root) => [root, new Set()])); + for (const event of Array.isArray(events) ? events : []) { + if (kindsByRoot.has(event?.root) && event?.kind) { + kindsByRoot.get(event.root).add(event.kind); + } + } + for (const [root, kindsSet] of kindsByRoot) { + const kinds = [...kindsSet]; out.set(root, { mirrored: kinds.length > 0, kinds, diff --git a/js/services/nip71MirrorSync.js b/js/services/nip71MirrorSync.js index 9091afa0..16a98403 100644 --- a/js/services/nip71MirrorSync.js +++ b/js/services/nip71MirrorSync.js @@ -17,9 +17,9 @@ import { } from "./nip71MirrorFlags.js"; import { devLogger } from "../utils/logger.js"; -// On publish of a NEW video, auto-mirror it when the account opted into -// "auto-share new public videos" and it's eligible. Sets the per-video flag so -// later edits/deletes keep it in sync. Best-effort. +// On publish of a NEW video, mirror it when the account default permits it and +// this upload has not explicitly opted out. Sets the per-video flag so later +// edits/deletes keep it in sync. Best-effort. export async function syncNip71MirrorAfterPublish(detail = {}) { const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : ""; const legacyEvent = detail?.result?.legacy; @@ -38,7 +38,8 @@ export async function syncNip71MirrorAfterPublish(detail = {}) { const withPubkey = { ...video, pubkey }; const decision = resolvePublishSync({ featureOn: FEATURE_NIP71_MIRROR, - autoShare: isAutoShareEnabled(pubkey), + autoShare: + isAutoShareEnabled(pubkey) && detail?.payload?.mirrorNip71 !== false, eligible: nip71MirrorService.canMirror(withPubkey).ok, }); if (decision.action !== "publish") { @@ -54,17 +55,38 @@ export async function syncNip71MirrorAfterPublish(detail = {}) { } } -export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) { +export async function syncNip71MirrorAfterEdit({ + updatedData, + originalEvent, + result, + pubkey, +} = {}) { if (!updatedData || typeof updatedData !== "object") { return; } + let publishedVideo = null; + try { + if (result && typeof result === "object") { + const parsed = convertEventToVideo(result); + if (parsed && !parsed.invalid) { + publishedVideo = parsed; + } + } + } catch (error) { + devLogger.warn("[nip71MirrorSync] couldn't parse edited video", error); + } const videoRootId = - typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : ""; + (typeof publishedVideo?.videoRootId === "string" && publishedVideo.videoRootId) || + (typeof updatedData.videoRootId === "string" && updatedData.videoRootId) || + (typeof originalEvent?.videoRootId === "string" && originalEvent.videoRootId) || + ""; const authorPubkey = typeof pubkey === "string" ? pubkey : ""; if (!videoRootId || !authorPubkey) { return; } - const video = { ...updatedData, pubkey: authorPubkey, videoRootId }; + // Prefer the actual signed canonical result: it includes fields preserved by + // the edit builder (root, dimensions, hashes) that the form does not resend. + const video = { ...updatedData, ...publishedVideo, pubkey: authorPubkey, videoRootId }; const decision = resolveEditSync({ featureOn: FEATURE_NIP71_MIRROR, enabled: isMirrorEnabled(authorPubkey, videoRootId), @@ -78,8 +100,12 @@ export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) { await nip71MirrorService.publish(video); } else { // "unshare": no longer eligible (e.g. flipped private) — pull it down. - await nip71MirrorService.remove(video); - setMirrorEnabled(authorPubkey, videoRootId, false); + const removal = await nip71MirrorService.remove(video); + // Keep the flag when the relay operation failed so a later edit can retry + // rather than silently orphaning a previously public mirror. + if (removal?.ok) { + setMirrorEnabled(authorPubkey, videoRootId, false); + } } } catch (error) { devLogger.warn("[nip71MirrorSync] edit sync failed", error); @@ -95,8 +121,13 @@ export function initNip71MirrorSync(nostrService) { return () => {}; } registered = true; - const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => { - void syncNip71MirrorAfterEdit({ updatedData, pubkey }); + const offEdit = nostrService.on("videos:edited", ({ + updatedData, + originalEvent, + result, + pubkey, + } = {}) => { + void syncNip71MirrorAfterEdit({ updatedData, originalEvent, result, pubkey }); }); const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => { void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey }); diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index b06e1e77..b6ca9eb5 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -459,6 +459,11 @@ export function normalizeVideoNotePayload(input) { const publishPayload = { legacyFormData, + // This is publish-session control metadata, not canonical video content. + // Keep it outside legacyFormData so it is never serialized into the 30078 + // event, but preserve the uploader's explicit NIP-71 opt-out for the + // post-publish mirror hook. + mirrorNip71: rawPayload.mirrorNip71 !== false, }; if (normalizedNip71) { diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index c69fd993..ce4d11d9 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -318,6 +318,7 @@ export class UploadModal { nsfw: $("#check-nsfw"), kids: $("#check-kids"), comments: $("#check-comments"), + noNip71Mirror: $("#check-no-nip71-mirror"), summaryUnlock: $("#check-summary-unlock"), advanced: $("#btn-advanced-toggle"), @@ -1212,6 +1213,10 @@ export class UploadModal { this.inputs.thumbnail?.value?.trim() || "", enableComments: this.toggles.comments?.checked || true, + // Public hosted videos mirror to NIP-71 by default. This per-upload + // checkbox is an explicit opt-out; eligibility still enforces the + // private/NSFW/HTTPS policy at the mirror boundary. + mirrorNip71: this.toggles.noNip71Mirror?.checked !== true, ...audienceFlags, // Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 0496f070..278f721c 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -601,10 +601,14 @@ export class MyVideosController { } } else { const result = await nip71MirrorService.remove(video); - setMirrorEnabled(this.pubkey, video.videoRootId, false); - this.mainController.showSuccess?.( - result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.", - ); + if (result?.ok) { + setMirrorEnabled(this.pubkey, video.videoRootId, false); + this.mainController.showSuccess?.("Removed from other Nostr apps."); + } else { + this.mainController.showError?.( + "Couldn't remove the shared mirror. Please try again.", + ); + } } } catch (error) { devLogger.warn("[myVideos] mirror toggle failed:", error); diff --git a/tests/nip71-mirror-flags.test.mjs b/tests/nip71-mirror-flags.test.mjs index 992fe006..1c11b555 100644 --- a/tests/nip71-mirror-flags.test.mjs +++ b/tests/nip71-mirror-flags.test.mjs @@ -85,14 +85,14 @@ test("resolveDeleteSync: attempts teardown whenever the feature is on, regardles assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: false }), { action: "none" }); }); -test("auto-share preference persists per pubkey, off by default", () => { +test("auto-share preference is on by default and supports a durable opt-out", () => { localStorage.clear(); - assert.equal(isAutoShareEnabled(PK), false); - setAutoShareEnabled(PK, true); assert.equal(isAutoShareEnabled(PK), true); - assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey"); setAutoShareEnabled(PK, false); assert.equal(isAutoShareEnabled(PK), false); + assert.equal(isAutoShareEnabled("c".repeat(64)), true, "default is scoped per pubkey"); + setAutoShareEnabled(PK, true); + assert.equal(isAutoShareEnabled(PK), true); }); test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => { diff --git a/tests/nip71-mirror-service.test.mjs b/tests/nip71-mirror-service.test.mjs index 921aaf28..2f2db88b 100644 --- a/tests/nip71-mirror-service.test.mjs +++ b/tests/nip71-mirror-service.test.mjs @@ -106,11 +106,11 @@ test("publish is unavailable without a signer / pubkey", async () => { assert.equal(result.error, "unavailable"); }); -test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => { +test("remove() publishes a NIP-09 delete and tombstones both addressable kinds", async () => { const { service, relay } = makeService(); const result = await service.remove(baseVideo()); assert.equal(result.ok, true); - assert.equal(relay.events.length, 2, "delete + empty-replace tombstone"); + assert.equal(relay.events.length, 3, "delete + empty replacements for both kinds"); const del = relay.events.find((e) => e.kind === 5); assert.ok(del, "must publish a NIP-09 kind-5 delete"); @@ -122,21 +122,19 @@ test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombston const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort(); assert.deepEqual(kTags, ["34235", "34236"]); - const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); - assert.ok(tomb, "must publish an empty-replace tombstone"); - assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag"); - assert.equal( - tomb.tags.some((t) => t[0] === "imeta"), - false, - "tombstone has no playable imeta", - ); + const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236); + assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]); + for (const tomb of tombs) { + assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag"); + assert.equal(tomb.tags.some((t) => t[0] === "imeta"), false, "not playable"); + } }); test("remove() tombstone uses the short kind for portrait videos", async () => { const { service, relay } = makeService(); await service.remove(baseVideo({ width: 1080, height: 1920 })); - const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); - assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone"); + const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236); + assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]); }); test("remove() is unavailable without a signer", async () => { @@ -150,7 +148,7 @@ test("remove() is unavailable without a signer", async () => { // (34236 short for portrait, else 34235), but (kind,pubkey,d) is the addressable // identity — so a kind flip between mirror attempts produced a DUPLICATE in NIP-71 // clients. publish() must reuse the kind of any existing mirror instead. -function makeServiceWithExisting(existing) { +function makeServiceWithExisting(existing, { now = () => Date.now() } = {}) { const relay = makeRelay(); const signer = { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) }; const service = createNip71MirrorService({ @@ -163,6 +161,7 @@ function makeServiceWithExisting(existing) { signEvent: signer.signEvent, allowNsfw: () => false, fetchExistingMirrors: async () => existing, + now, }); return { service, relay }; } @@ -208,6 +207,48 @@ test("an explicit options.short override is respected over any existing mirror", assert.equal(relay.events[0].kind, 34235, "explicit override wins"); }); +test("re-mirror advances past an existing addressable timestamp", async () => { + const { service, relay } = makeServiceWithExisting( + [{ kind: 34235, created_at: 500 }], + { now: () => 500_000 }, + ); + const result = await service.publish(baseVideo()); + assert.equal(result.ok, true); + assert.equal(relay.events[0].created_at, 501, "mirror replacement must win its coordinate"); +}); + +test("remove advances its delete and tombstones past an existing mirror", async () => { + const { service, relay } = makeServiceWithExisting( + [{ kind: 34235, created_at: 500 }], + { now: () => 500_000 }, + ); + const result = await service.remove(baseVideo()); + assert.equal(result.ok, true); + assert.deepEqual(relay.events.map((event) => event.created_at), [501, 501, 501]); +}); + +test("findMirrors resolves a library in one batched relay lookup", async () => { + let batchCalls = 0; + const service = createNip71MirrorService({ + getActivePubkey: () => PUBKEY, + fetchMirrorsByRoots: async ({ roots }) => { + batchCalls += 1; + assert.deepEqual(roots.sort(), ["root-a", "root-b"]); + return [ + { root: "root-a", kind: 34235, created_at: 10 }, + { root: "root-b", kind: 34236, created_at: 11 }, + ]; + }, + }); + const states = await service.findMirrors([ + { videoRootId: "root-a" }, + { videoRootId: "root-b" }, + ]); + assert.equal(batchCalls, 1); + assert.deepEqual(states.get("root-a"), { mirrored: true, kinds: [34235], duplicate: false }); + assert.deepEqual(states.get("root-b"), { mirrored: true, kinds: [34236], duplicate: false }); +}); + test("findMirror derives mirror state from relays (truth), flagging duplicates", async () => { const dup = makeServiceWithExisting([{ kind: 34235 }, { kind: 34236 }]).service; assert.deepEqual(await dup.findMirror(baseVideo()), { diff --git a/tests/nip71-mirror-sync.test.mjs b/tests/nip71-mirror-sync.test.mjs index 252a5191..189b8e8c 100644 --- a/tests/nip71-mirror-sync.test.mjs +++ b/tests/nip71-mirror-sync.test.mjs @@ -6,10 +6,16 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { + syncNip71MirrorAfterEdit, + syncNip71MirrorAfterPublish, syncNip71MirrorAfterDelete, initNip71MirrorSync, } from "../js/services/nip71MirrorSync.js"; -import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js"; +import { + isMirrorEnabled, + setMirrorEnabled, + setAutoShareEnabled, +} from "../js/services/nip71MirrorFlags.js"; import { nip71MirrorService } from "../js/services/nip71MirrorService.js"; const PK = "a".repeat(64); @@ -63,6 +69,102 @@ test("delete sync never throws when teardown fails (best-effort)", async () => { } }); +test("editing a mirrored video uses the original root when the form omits it", async () => { + localStorage.clear(); + setMirrorEnabled(PK, "root-edit", true); + const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true })); + const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true })); + try { + await syncNip71MirrorAfterEdit({ + updatedData: { title: "Edited", url: "https://cdn.example/video.mp4" }, + originalEvent: { videoRootId: "root-edit" }, + pubkey: PK, + }); + assert.equal(publishSpy.mock.callCount(), 1, "mirror is republished after a normal edit"); + assert.equal(publishSpy.mock.calls[0].arguments[0].videoRootId, "root-edit"); + } finally { + canMirror.mock.restore(); + publishSpy.mock.restore(); + } +}); + +test("failed edit-time unshare keeps the flag so a later edit retries cleanup", async () => { + localStorage.clear(); + setMirrorEnabled(PK, "root-retry", true); + const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: false, reason: "private" })); + const removeSpy = mock.method(nip71MirrorService, "remove", async () => ({ ok: false })); + try { + await syncNip71MirrorAfterEdit({ + updatedData: { isPrivate: true }, + originalEvent: { videoRootId: "root-retry" }, + pubkey: PK, + }); + assert.equal(removeSpy.mock.callCount(), 1); + assert.equal(isMirrorEnabled(PK, "root-retry"), true, "failed cleanup stays retryable"); + } finally { + canMirror.mock.restore(); + removeSpy.mock.restore(); + } +}); + +test("a per-upload opt-out prevents the default NIP-71 mirror", async () => { + localStorage.clear(); + setAutoShareEnabled(PK, true); + const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true })); + try { + await syncNip71MirrorAfterPublish({ + pubkey: PK, + payload: { mirrorNip71: false }, + result: { + legacy: { + id: "event-id", + pubkey: PK, + created_at: 1, + tags: [["d", "root-opt-out"]], + content: JSON.stringify({ + videoRootId: "root-opt-out", + title: "No mirror", + url: "https://cdn.example/video.mp4", + }), + }, + }, + }); + assert.equal(publishSpy.mock.callCount(), 0); + } finally { + publishSpy.mock.restore(); + } +}); + +test("a new eligible upload mirrors by default", async () => { + localStorage.clear(); + const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true })); + const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true })); + try { + await syncNip71MirrorAfterPublish({ + pubkey: PK, + payload: { mirrorNip71: true }, + result: { + legacy: { + id: "event-id-default", + pubkey: PK, + created_at: 1, + tags: [["d", "root-default"]], + content: JSON.stringify({ + videoRootId: "root-default", + title: "Default mirror", + url: "https://cdn.example/video.mp4", + }), + }, + }, + }); + assert.equal(publishSpy.mock.callCount(), 1); + assert.equal(isMirrorEnabled(PK, "root-default"), true); + } finally { + canMirror.mock.restore(); + publishSpy.mock.restore(); + } +}); + test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => { const events = {}; const svc = { diff --git a/tests/video-note-payload.test.mjs b/tests/video-note-payload.test.mjs index d8c3c904..2b6f80fe 100644 --- a/tests/video-note-payload.test.mjs +++ b/tests/video-note-payload.test.mjs @@ -28,6 +28,22 @@ test("normalizes minimal payload with hosted URL", () => { assert.equal(payload.legacyFormData.enableComments, true); assert.equal(payload.legacyFormData.isNsfw, false); assert.equal(payload.legacyFormData.isForKids, false); + assert.equal(payload.mirrorNip71, true, "public uploads mirror by default"); +}); + +test("preserves an explicit per-upload NIP-71 mirror opt-out outside canonical content", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Private distribution choice", + url: "https://cdn.example.com/video.mp4", + mirrorNip71: false, + }); + assert.deepEqual(errors, []); + assert.equal(payload.mirrorNip71, false); + assert.equal( + Object.hasOwn(payload.legacyFormData, "mirrorNip71"), + false, + "opt-out does not leak into the canonical 30078 payload", + ); }); test("normalizes mode and boolean flags", () => {