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

+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);