From 783c7df0b78fcca72f4bce28011cffd0f4a8718e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:40:09 -0400 Subject: [PATCH] feat(nip71): My Videos opt-in "Share to apps" mirror toggle (1c/1d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the NIP-71 mirror user-facing via the My Videos tab — the natural manage-my-videos surface — without touching the single-writer publish path. - js/constants.js: new FEATURE_NIP71_MIRROR flag (default on). Independent of FEATURE_PUBLISH_NIP71, which is already wired to the LEGACY 21/22 auto-publish in videoPublisher.js — flipping that would activate the wrong (non-opt-in) model, so the opt-in mirror gets its own flag and that legacy path stays dormant. - js/services/nip71MirrorFlags.js: per-video opt-in persistence (pubkey+videoRootId, off by default) + pure resolveMirrorToggle decision. - MyVideosController: per-row "Share to apps" / "Shared ✓" toggle (gated by the flag), wired to nip71MirrorService.publish/remove. Ineligible videos show a disabled "Share off" with the reason (private / NSFW-not-allowed-here / needs hosted URL) — the ALLOW_NSFW_CONTENT moderation gate surfaces here too. tests/nip71-mirror-flags.test.mjs: persistence scoping + toggle decision (enabled→remove, eligible→publish, ineligible→blocked). Mutation-verified. Co-Authored-By: Claude Opus 4.8 --- js/constants.js | 9 +++ js/services/nip71MirrorFlags.js | 77 ++++++++++++++++++++++ js/ui/profileModal/MyVideosController.js | 84 ++++++++++++++++++++++++ tests/nip71-mirror-flags.test.mjs | 56 ++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 js/services/nip71MirrorFlags.js create mode 100644 tests/nip71-mirror-flags.test.mjs diff --git a/js/constants.js b/js/constants.js index b664ae45..f6a9c202 100644 --- a/js/constants.js +++ b/js/constants.js @@ -182,6 +182,9 @@ const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player FEATURE_WATCH_HISTORY_V2: true, FEATURE_PUBLISH_NIP71: false, + // Opt-in NIP-71 *mirror* (addressable 34235/36) managed per-video from the + // My Videos tab. Independent of FEATURE_PUBLISH_NIP71 (legacy 21/22 auto-publish). + FEATURE_NIP71_MIRROR: true, FEATURE_HASHTAG_PREFERENCES: false, FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE), FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes. @@ -202,6 +205,7 @@ const runtimeFlags = (() => { URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED, FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71, + FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR, FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES, FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS, FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS, @@ -233,6 +237,11 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean( DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71 ); +export let FEATURE_NIP71_MIRROR = coerceBoolean( + runtimeFlags.FEATURE_NIP71_MIRROR, + DEFAULT_FLAGS.FEATURE_NIP71_MIRROR +); + export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean( runtimeFlags.FEATURE_HASHTAG_PREFERENCES, DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES diff --git a/js/services/nip71MirrorFlags.js b/js/services/nip71MirrorFlags.js new file mode 100644 index 00000000..7c733f6b --- /dev/null +++ b/js/services/nip71MirrorFlags.js @@ -0,0 +1,77 @@ +// Per-video opt-in state for the NIP-71 mirror, persisted locally per +// pubkey+videoRootId. Off by default — a publisher explicitly opts a video in +// (from the My Videos tab). Local-only is fine: it's a convenience for the +// management UI; the authoritative state is whether the mirror event exists on +// relays. + +const FLAG_KEY = "bitvid:nip71-mirror:v1"; + +function readFlags() { + try { + if (typeof localStorage === "undefined") { + return {}; + } + const raw = localStorage.getItem(FLAG_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +function writeFlags(flags) { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(FLAG_KEY, JSON.stringify(flags || {})); + } + } catch (error) { + // Best-effort. + } +} + +function norm(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function isMirrorEnabled(pubkey, videoRootId) { + const pk = norm(pubkey); + const root = norm(videoRootId); + if (!pk || !root) { + return false; + } + return readFlags()[pk]?.[root] === true; +} + +export function setMirrorEnabled(pubkey, videoRootId, enabled) { + const pk = norm(pubkey); + const root = norm(videoRootId); + if (!pk || !root) { + return; + } + const flags = readFlags(); + const entry = flags[pk] && typeof flags[pk] === "object" ? flags[pk] : {}; + if (enabled === true) { + entry[root] = true; + } else { + delete entry[root]; + } + flags[pk] = entry; + writeFlags(flags); +} + +// Pure decision for a toggle click: given the current opt-in state and the +// service's eligibility check (canMirror), what should happen? +// { action: "remove" } — currently on → tear down +// { action: "publish" } — eligible + off → publish +// { action: "blocked", reason: "" } — off but ineligible (private / nsfw / no-url) +export function resolveMirrorToggle({ enabled, eligibility } = {}) { + if (enabled === true) { + return { action: "remove" }; + } + if (!eligibility || eligibility.ok !== true) { + return { action: "blocked", reason: eligibility?.reason || "ineligible" }; + } + return { action: "publish" }; +} + +export const NIP71_MIRROR_FLAG_KEY = FLAG_KEY; diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 45035de5..da22c043 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -4,6 +4,20 @@ import { getApplication } from "../../applicationContext.js"; import { collapseUserVideos } from "./myVideosData.js"; import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js"; import { reconcileStorage } from "./myVideosReconcile.js"; +import { FEATURE_NIP71_MIRROR } from "../../constants.js"; +import { nip71MirrorService } from "../../services/nip71MirrorService.js"; +import { + isMirrorEnabled, + setMirrorEnabled, + resolveMirrorToggle, +} from "../../services/nip71MirrorFlags.js"; + +const MIRROR_REASON_TEXT = { + private: "Private videos can't be shared to other apps.", + "nsfw-blocked": "NSFW videos aren't shared from this instance.", + "no-url": "Needs a hosted (HTTPS) URL to appear on other apps.", + invalid: "This video can't be mirrored.", +}; // Maps a health severity to a status-pill style, mirroring // ProfileRelayController.applyStatusPill so the look stays consistent. @@ -369,6 +383,10 @@ export class MyVideosController { actions.appendChild( this.buildActionButton("Edit", () => this.handleEdit(video)), ); + const mirrorBtn = this.buildMirrorButton(video); + if (mirrorBtn) { + actions.appendChild(mirrorBtn); + } actions.appendChild( this.buildActionButton("Delete", () => this.handleDelete(video), true), ); @@ -376,6 +394,72 @@ export class MyVideosController { return actions; } + // Opt-in "publish to other Nostr video apps (NIP-71)" toggle. Reflects the + // per-video opt-in flag; disabled (with a reason) for ineligible videos. + buildMirrorButton(video) { + if (!FEATURE_NIP71_MIRROR) { + return null; + } + const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); + const eligibility = nip71MirrorService.canMirror(video); + if (!enabled && eligibility.ok !== true) { + const reason = MIRROR_REASON_TEXT[eligibility.reason] || "Can't be shared."; + const btn = this.buildActionButton("Share off", () => { + this.mainController.showStatus?.(reason); + }); + btn.disabled = true; + btn.title = reason; + btn.classList.add("opacity-50"); + return btn; + } + return this.buildActionButton( + enabled ? "Shared ✓" : "Share to apps", + (event) => this.handleToggleMirror(video, event?.currentTarget || null), + ); + } + + async handleToggleMirror(video, btn) { + const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); + const eligibility = nip71MirrorService.canMirror(video); + const decision = resolveMirrorToggle({ enabled, eligibility }); + + if (decision.action === "blocked") { + this.mainController.showError?.( + MIRROR_REASON_TEXT[decision.reason] || "This video can't be shared.", + ); + return; + } + if (btn) { + btn.disabled = true; + } + try { + if (decision.action === "publish") { + const result = await nip71MirrorService.publish(video); + if (result?.ok) { + setMirrorEnabled(this.pubkey, video.videoRootId, true); + this.mainController.showSuccess?.( + `Shared to other Nostr apps (${result.accepted}/${result.total} relays).`, + ); + } else { + this.mainController.showError?.( + "Couldn't share this video. Please try again.", + ); + } + } 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.", + ); + } + } catch (error) { + devLogger.warn("[myVideos] mirror toggle failed:", error); + this.mainController.showError?.("Sharing action failed. Please try again."); + } finally { + void this.populate({ forceFetch: true }); + } + } + buildActionButton(label, onClick, danger = false) { const btn = document.createElement("button"); btn.type = "button"; diff --git a/tests/nip71-mirror-flags.test.mjs b/tests/nip71-mirror-flags.test.mjs new file mode 100644 index 00000000..84558e64 --- /dev/null +++ b/tests/nip71-mirror-flags.test.mjs @@ -0,0 +1,56 @@ +// Per-video NIP-71 mirror opt-in: persistence (per pubkey+videoRootId) and the +// pure toggle-decision used by the My Videos tab. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isMirrorEnabled, + setMirrorEnabled, + resolveMirrorToggle, +} from "../js/services/nip71MirrorFlags.js"; + +const PK = "a".repeat(64); + +test("opt-in flag persists per pubkey+videoRootId and is off by default", () => { + localStorage.clear(); + assert.equal(isMirrorEnabled(PK, "root-1"), false, "off by default"); + + setMirrorEnabled(PK, "root-1", true); + assert.equal(isMirrorEnabled(PK, "root-1"), true); + assert.equal(isMirrorEnabled(PK, "root-2"), false, "scoped per videoRootId"); + assert.equal(isMirrorEnabled("b".repeat(64), "root-1"), false, "scoped per pubkey"); + + setMirrorEnabled(PK, "root-1", false); + assert.equal(isMirrorEnabled(PK, "root-1"), false, "can be turned back off"); +}); + +test("resolveMirrorToggle: enabled => remove", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: true, eligibility: { ok: true } }), + { action: "remove" }, + ); + // remove even if currently ineligible (so a now-private video can be pulled). + assert.deepEqual( + resolveMirrorToggle({ enabled: true, eligibility: { ok: false, reason: "private" } }), + { action: "remove" }, + ); +}); + +test("resolveMirrorToggle: off + eligible => publish", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: false, eligibility: { ok: true } }), + { action: "publish" }, + ); +}); + +test("resolveMirrorToggle: off + ineligible => blocked with reason", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: false, eligibility: { ok: false, reason: "no-url" } }), + { action: "blocked", reason: "no-url" }, + ); + assert.deepEqual( + resolveMirrorToggle({ enabled: false }), + { action: "blocked", reason: "ineligible" }, + ); +});