fix(nip71): keep upload mirrors in sync

This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 12:51:00 -04:00
1 parent f0a3ede3e5
commit e8e2b04de7
12 files changed
+325 -82

No files matched your search

+1 -1
View File
@@ -1179,7 +1179,7 @@ https://another.blossom.example"></textarea>
></p>
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
<span>Mirror new public videos to other Nostr apps by default (NIP-71)</span>
</label>
<p
id="profileMyVideosLoading"
+4
View File
@@ -309,6 +309,10 @@
<input type="checkbox" id="check-kids" class="checkbox" />
<span class="text-sm text-text">For Kids</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" id="check-no-nip71-mirror" class="checkbox" />
<span class="text-sm text-text">Do not share this video to other Nostr apps</span>
</label>
</div>
<!-- Advanced Options -->
+9 -11
View File
@@ -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" };
+74 -37
View File
@@ -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,
+42 -11
View File
@@ -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 });
+5
View File
@@ -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) {
+5
View File
@@ -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
+8 -4
View File
@@ -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);
+4 -4
View File
@@ -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", () => {
+54 -13
View File
@@ -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()), {
+103 -1
View File
@@ -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 = {
+16
View File
@@ -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", () => {