mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(nip71): auto-sync the mirror on edit/delete (event-driven, no nostrService growth)
Closes the dual-event drift the user flagged: an opted-in 34235 mirror now stays in lockstep with the canonical 30078 automatically — no per-video babysitting. - js/services/nip71MirrorSync.js: syncNip71MirrorAfterEdit (re-publish if still eligible; unshare + clear flag if it became ineligible, e.g. flipped private), syncNip71MirrorAfterDelete (remove + clear flag). Plus initNip71MirrorSync() which subscribes to nostrService's EXISTING videos:edited/videos:deleted events — so the size-capped nostrService is untouched. - js/services/nip71MirrorFlags.js: pure resolveEditSync/resolveDeleteSync decisions. - applicationBootstrap: one-line initNip71MirrorSync(app.nostrService) wiring. - All best-effort: a mirror failure never breaks the core edit/delete. tests: resolver decisions (lockstep/unshare/none) + delete clears the flag + event wiring is idempotent. Mutation-verified (eligibility branch load-bearing). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
dff1a27c0c
commit
89d9a62b87
5 files changed
+182
No files matched your search
@@ -74,4 +74,24 @@ export function resolveMirrorToggle({ enabled, eligibility } = {}) {
|
||||
return { action: "publish" };
|
||||
}
|
||||
|
||||
// Lifecycle decisions for keeping an opted-in mirror in sync (used by the
|
||||
// nostrService edit/delete hooks). Pure so they're cheat-resistant to test.
|
||||
//
|
||||
// On EDIT of a mirrored video: re-publish if still eligible; if it became
|
||||
// ineligible (e.g. flipped private), pull the mirror down and clear the flag.
|
||||
export function resolveEditSync({ featureOn, enabled, eligible } = {}) {
|
||||
if (featureOn !== true || enabled !== true) {
|
||||
return { action: "none" };
|
||||
}
|
||||
return eligible === true ? { action: "publish" } : { action: "unshare" };
|
||||
}
|
||||
|
||||
// On DELETE of a mirrored video: remove the mirror and clear the flag.
|
||||
export function resolveDeleteSync({ featureOn, enabled } = {}) {
|
||||
if (featureOn !== true || enabled !== true) {
|
||||
return { action: "none" };
|
||||
}
|
||||
return { action: "unshare" };
|
||||
}
|
||||
|
||||
export const NIP71_MIRROR_FLAG_KEY = FLAG_KEY;
|
||||
@@ -0,0 +1,95 @@
|
||||
// Keeps an opted-in NIP-71 mirror (kind 34235/36) in lockstep with the canonical
|
||||
// kind-30078 video on edit/delete — the "no UX fuss" glue so the two events never
|
||||
// drift. Extracted from nostrService (which is size-capped); called from its
|
||||
// edit/delete handlers. All best-effort: a mirror failure must never break the
|
||||
// core edit/delete.
|
||||
|
||||
import { FEATURE_NIP71_MIRROR } from "../constants.js";
|
||||
import { nip71MirrorService } from "./nip71MirrorService.js";
|
||||
import {
|
||||
isMirrorEnabled,
|
||||
setMirrorEnabled,
|
||||
resolveEditSync,
|
||||
resolveDeleteSync,
|
||||
} from "./nip71MirrorFlags.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
|
||||
if (!updatedData || typeof updatedData !== "object") {
|
||||
return;
|
||||
}
|
||||
const videoRootId =
|
||||
typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : "";
|
||||
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
|
||||
if (!videoRootId || !authorPubkey) {
|
||||
return;
|
||||
}
|
||||
const video = { ...updatedData, pubkey: authorPubkey, videoRootId };
|
||||
const decision = resolveEditSync({
|
||||
featureOn: FEATURE_NIP71_MIRROR,
|
||||
enabled: isMirrorEnabled(authorPubkey, videoRootId),
|
||||
eligible: nip71MirrorService.canMirror(video).ok,
|
||||
});
|
||||
if (decision.action === "none") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (decision.action === "publish") {
|
||||
await nip71MirrorService.publish(video);
|
||||
} else {
|
||||
// "unshare": no longer eligible (e.g. flipped private) — pull it down.
|
||||
await nip71MirrorService.remove(video);
|
||||
setMirrorEnabled(authorPubkey, videoRootId, false);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip71MirrorSync] edit sync failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the lifecycle off nostrService's existing "videos:edited"/"videos:deleted"
|
||||
// events — so the size-capped nostrService needs no changes. Idempotent: a guard
|
||||
// prevents double-registration. Returns an unsubscribe fn.
|
||||
let registered = false;
|
||||
export function initNip71MirrorSync(nostrService) {
|
||||
if (!nostrService || typeof nostrService.on !== "function" || registered) {
|
||||
return () => {};
|
||||
}
|
||||
registered = true;
|
||||
const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => {
|
||||
void syncNip71MirrorAfterEdit({ updatedData, pubkey });
|
||||
});
|
||||
const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => {
|
||||
void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey });
|
||||
});
|
||||
return () => {
|
||||
registered = false;
|
||||
if (typeof offEdit === "function") offEdit();
|
||||
if (typeof offDelete === "function") offDelete();
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncNip71MirrorAfterDelete({ videoRootId, video, pubkey } = {}) {
|
||||
const root = typeof videoRootId === "string" ? videoRootId : "";
|
||||
const authorPubkey = typeof pubkey === "string" ? pubkey : "";
|
||||
if (!root || !authorPubkey) {
|
||||
return;
|
||||
}
|
||||
const decision = resolveDeleteSync({
|
||||
featureOn: FEATURE_NIP71_MIRROR,
|
||||
enabled: isMirrorEnabled(authorPubkey, root),
|
||||
});
|
||||
if (decision.action === "none") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await nip71MirrorService.remove({
|
||||
...(video || {}),
|
||||
videoRootId: root,
|
||||
pubkey: authorPubkey,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[nip71MirrorSync] delete sync failed", error);
|
||||
} finally {
|
||||
setMirrorEnabled(authorPubkey, root, false);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import hashtagPreferences, {
|
||||
import NwcSettingsService from "../services/nwcSettingsService.js";
|
||||
import nostrService from "../services/nostrService.js";
|
||||
import storageService from "../services/storageService.js";
|
||||
import { initNip71MirrorSync } from "../services/nip71MirrorSync.js";
|
||||
import watchHistoryService from "../watchHistoryService.js";
|
||||
import r2Service from "../services/r2Service.js";
|
||||
import s3UploadService from "../services/s3UploadService.js";
|
||||
@@ -849,6 +850,9 @@ export default class ApplicationBootstrap {
|
||||
app.videoSubscription = subscription || null;
|
||||
}),
|
||||
);
|
||||
// Keep an opted-in NIP-71 mirror in lockstep on edit/delete (event-driven so
|
||||
// nostrService stays untouched).
|
||||
nostrUnsubscribes.push(initNip71MirrorSync(app.nostrService));
|
||||
nostrUnsubscribes.push(
|
||||
app.nostrService.on("directMessages:notification", () => {
|
||||
void app.refreshUnreadDmIndicator({ reason: "dm-notification" });
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
isMirrorEnabled,
|
||||
setMirrorEnabled,
|
||||
resolveMirrorToggle,
|
||||
resolveEditSync,
|
||||
resolveDeleteSync,
|
||||
} from "../js/services/nip71MirrorFlags.js";
|
||||
|
||||
const PK = "a".repeat(64);
|
||||
@@ -54,3 +56,19 @@ test("resolveMirrorToggle: off + ineligible => blocked with reason", () => {
|
||||
{ action: "blocked", reason: "ineligible" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEditSync: keeps a mirrored video in lockstep, unshares when ineligible", () => {
|
||||
// not mirrored, or feature off → do nothing
|
||||
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: false, eligible: true }), { action: "none" });
|
||||
assert.deepEqual(resolveEditSync({ featureOn: false, enabled: true, eligible: true }), { action: "none" });
|
||||
// mirrored + still eligible → re-publish (no drift)
|
||||
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: true }), { action: "publish" });
|
||||
// mirrored but became ineligible (e.g. now private) → pull it down
|
||||
assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: false }), { action: "unshare" });
|
||||
});
|
||||
|
||||
test("resolveDeleteSync: removes the mirror only when it was shared", () => {
|
||||
assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: true }), { action: "unshare" });
|
||||
assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "none" });
|
||||
assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: true }), { action: "none" });
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Auto-sync glue: keeps an opted-in NIP-71 mirror in lockstep on edit/delete,
|
||||
// driven by nostrService's existing videos:edited/videos:deleted events (so the
|
||||
// size-capped nostrService is untouched).
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
syncNip71MirrorAfterDelete,
|
||||
initNip71MirrorSync,
|
||||
} from "../js/services/nip71MirrorSync.js";
|
||||
import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js";
|
||||
|
||||
const PK = "a".repeat(64);
|
||||
|
||||
test("deleting a SHARED video clears its opt-in flag (mirror torn down)", async () => {
|
||||
localStorage.clear();
|
||||
setMirrorEnabled(PK, "root-x", true);
|
||||
await syncNip71MirrorAfterDelete({ videoRootId: "root-x", video: {}, pubkey: PK });
|
||||
assert.equal(isMirrorEnabled(PK, "root-x"), false, "flag cleared after delete");
|
||||
});
|
||||
|
||||
test("deleting a NON-shared video is a no-op (and never throws)", async () => {
|
||||
localStorage.clear();
|
||||
await syncNip71MirrorAfterDelete({ videoRootId: "root-y", video: {}, pubkey: PK });
|
||||
assert.equal(isMirrorEnabled(PK, "root-y"), false);
|
||||
});
|
||||
|
||||
test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => {
|
||||
const events = {};
|
||||
const svc = {
|
||||
on(name, handler) {
|
||||
(events[name] = events[name] || []).push(handler);
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const off = initNip71MirrorSync(svc);
|
||||
assert.equal(events["videos:edited"]?.length, 1, "subscribes to videos:edited");
|
||||
assert.equal(events["videos:deleted"]?.length, 1, "subscribes to videos:deleted");
|
||||
|
||||
initNip71MirrorSync(svc); // guard prevents double-registration
|
||||
assert.equal(events["videos:edited"].length, 1, "no double-register");
|
||||
|
||||
off(); // resets the guard for other tests
|
||||
});
|
||||
Reference in new issue
Block a user