diff --git a/js/services/nip71MirrorFlags.js b/js/services/nip71MirrorFlags.js index 7c733f6b..ebe7f95e 100644 --- a/js/services/nip71MirrorFlags.js +++ b/js/services/nip71MirrorFlags.js @@ -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; diff --git a/js/services/nip71MirrorSync.js b/js/services/nip71MirrorSync.js new file mode 100644 index 00000000..853e0ae2 --- /dev/null +++ b/js/services/nip71MirrorSync.js @@ -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); + } +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 63c44fb1..fc79375e 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -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" }); diff --git a/tests/nip71-mirror-flags.test.mjs b/tests/nip71-mirror-flags.test.mjs index 84558e64..f3d36245 100644 --- a/tests/nip71-mirror-flags.test.mjs +++ b/tests/nip71-mirror-flags.test.mjs @@ -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" }); +}); diff --git a/tests/nip71-mirror-sync.test.mjs b/tests/nip71-mirror-sync.test.mjs new file mode 100644 index 00000000..9bca3c29 --- /dev/null +++ b/tests/nip71-mirror-sync.test.mjs @@ -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 +});