mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(nip71): keep upload mirrors in sync
This commit is contained in:
1 parent
f0a3ede3e5
commit
e8e2b04de7
12 files changed
+325
-82
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()), {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user