feat(nip71): My Videos opt-in "Share to apps" mirror toggle (1c/1d)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 09:40:09 -04:00
1 parent 0c92a259c5
commit 783c7df0b7
4 files changed
+226

No files matched your search

+9
View File
@@ -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
+77
View File
@@ -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: "<why>" } — 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;
+84
View File
@@ -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";
+56
View File
@@ -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" },
);
});