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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user