nip71: account-level "auto-share new public videos" toggle

Adds an opt-in account preference (off by default, per pubkey) that
auto-mirrors newly published eligible public videos to NIP-71
(kinds 34235/34236) so they surface on other Nostr video clients —
no per-video opt-in needed.

- nip71MirrorFlags: isAutoShareEnabled/setAutoShareEnabled (persisted
  per pubkey) + pure resolvePublishSync({featureOn,autoShare,eligible}).
- nip71MirrorSync: syncNip71MirrorAfterPublish + a videos:published
  subscription in initNip71MirrorSync; converts the canonical 30078
  event, checks eligibility (public + hosted URL + ALLOW_NSFW gate),
  publishes the mirror, and records the per-video flag so later
  edits/deletes stay in lockstep. All best-effort.
- My Videos pane: an "Auto-share new public videos to other Nostr apps"
  checkbox wired to setAutoShareEnabled, gated by FEATURE_NIP71_MIRROR.

Tests (mutation-verified): auto-share persistence, resolvePublishSync
(eligible+on=>publish; off/ineligible=>none), and videos:published
subscription wiring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 14:06:19 -04:00
1 parent 115521928c
commit 685e52656b
6 files changed
+149

No files matched your search

+4
View File
@@ -1028,6 +1028,10 @@
role="status"
aria-live="polite"
></p>
<label id="profileMyVideosAutoShareRow" class="hidden flex items-start gap-2 text-sm text-muted-strong">
<input id="profileMyVideosAutoShare" type="checkbox" class="checkbox mt-0.5" />
<span>Auto-share new public videos to other Nostr apps (NIP-71)</span>
</label>
<p
id="profileMyVideosLoading"
class="text-sm text-muted hidden"
+51
View File
@@ -5,6 +5,7 @@
// relays.
const FLAG_KEY = "bitvid:nip71-mirror:v1";
const AUTO_SHARE_KEY = "bitvid:nip71-autoshare:v1";
function readFlags() {
try {
@@ -74,6 +75,56 @@ 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.
function readAutoShare() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(AUTO_SHARE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
export function isAutoShareEnabled(pubkey) {
const pk = norm(pubkey);
return pk ? readAutoShare()[pk] === true : false;
}
export function setAutoShareEnabled(pubkey, enabled) {
const pk = norm(pubkey);
if (!pk) {
return;
}
const map = readAutoShare();
if (enabled === true) {
map[pk] = true;
} else {
delete map[pk];
}
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map));
}
} catch (error) {
// best-effort
}
}
// 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.
export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) {
if (featureOn !== true || autoShare !== true) {
return { action: "none" };
}
return eligible === true ? { action: "publish" } : { action: "none" };
}
// 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.
//
+44
View File
@@ -6,14 +6,54 @@
import { FEATURE_NIP71_MIRROR } from "../constants.js";
import { nip71MirrorService } from "./nip71MirrorService.js";
import { convertEventToVideo } from "../nostr/index.js";
import {
isMirrorEnabled,
setMirrorEnabled,
isAutoShareEnabled,
resolveEditSync,
resolveDeleteSync,
resolvePublishSync,
} 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.
export async function syncNip71MirrorAfterPublish(detail = {}) {
const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : "";
const legacyEvent = detail?.result?.legacy;
if (!pubkey || !legacyEvent || typeof legacyEvent !== "object") {
return;
}
let video;
try {
video = convertEventToVideo(legacyEvent);
} catch (error) {
return;
}
if (!video || video.invalid || !video.videoRootId) {
return;
}
const withPubkey = { ...video, pubkey };
const decision = resolvePublishSync({
featureOn: FEATURE_NIP71_MIRROR,
autoShare: isAutoShareEnabled(pubkey),
eligible: nip71MirrorService.canMirror(withPubkey).ok,
});
if (decision.action !== "publish") {
return;
}
try {
const result = await nip71MirrorService.publish(withPubkey);
if (result?.ok) {
setMirrorEnabled(pubkey, video.videoRootId, true);
}
} catch (error) {
devLogger.warn("[nip71MirrorSync] auto-share publish failed", error);
}
}
export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) {
if (!updatedData || typeof updatedData !== "object") {
return;
@@ -61,10 +101,14 @@ export function initNip71MirrorSync(nostrService) {
const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => {
void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey });
});
const offPublish = nostrService.on("videos:published", (detail) => {
void syncNip71MirrorAfterPublish(detail);
});
return () => {
registered = false;
if (typeof offEdit === "function") offEdit();
if (typeof offDelete === "function") offDelete();
if (typeof offPublish === "function") offPublish();
};
}
+29
View File
@@ -10,6 +10,8 @@ import {
isMirrorEnabled,
setMirrorEnabled,
resolveMirrorToggle,
isAutoShareEnabled,
setAutoShareEnabled,
} from "../../services/nip71MirrorFlags.js";
const MIRROR_REASON_TEXT = {
@@ -48,6 +50,8 @@ export class MyVideosController {
this.refreshBtn = null;
this.orphansSection = null;
this.orphanListEl = null;
this.autoShareRow = null;
this.autoShareInput = null;
this.loading = false;
this.publicBaseUrl = "";
this.pubkey = "";
@@ -63,6 +67,10 @@ export class MyVideosController {
document.getElementById("profileMyVideosOrphans") || null;
this.orphanListEl =
document.getElementById("profileMyVideosOrphanList") || null;
this.autoShareRow =
document.getElementById("profileMyVideosAutoShareRow") || null;
this.autoShareInput =
document.getElementById("profileMyVideosAutoShare") || null;
}
registerEventListeners() {
@@ -71,6 +79,26 @@ export class MyVideosController {
void this.populate({ forceFetch: true });
});
}
if (this.autoShareInput instanceof HTMLInputElement) {
this.autoShareInput.addEventListener("change", () => {
const pubkey = this.getPubkey();
if (!pubkey) {
return;
}
setAutoShareEnabled(pubkey, this.autoShareInput.checked === true);
});
}
}
refreshAutoShareToggle(pubkey) {
if (!(this.autoShareRow instanceof HTMLElement)) {
return;
}
const eligible = FEATURE_NIP71_MIRROR && !!pubkey;
this.autoShareRow.classList.toggle("hidden", !eligible);
if (eligible && this.autoShareInput instanceof HTMLInputElement) {
this.autoShareInput.checked = isAutoShareEnabled(pubkey);
}
}
getPubkey() {
@@ -114,6 +142,7 @@ export class MyVideosController {
return;
}
const pubkey = this.getPubkey();
this.refreshAutoShareToggle(pubkey);
if (!pubkey) {
this.renderRows([]);
this.setSummary("");
+20
View File
@@ -10,6 +10,9 @@ import {
resolveMirrorToggle,
resolveEditSync,
resolveDeleteSync,
isAutoShareEnabled,
setAutoShareEnabled,
resolvePublishSync,
} from "../js/services/nip71MirrorFlags.js";
const PK = "a".repeat(64);
@@ -72,3 +75,20 @@ test("resolveDeleteSync: removes the mirror only when it was shared", () => {
assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "none" });
assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: true }), { action: "none" });
});
test("auto-share preference persists per pubkey, off by default", () => {
localStorage.clear();
assert.equal(isAutoShareEnabled(PK), false);
setAutoShareEnabled(PK, true);
assert.equal(isAutoShareEnabled(PK), true);
assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey");
setAutoShareEnabled(PK, false);
assert.equal(isAutoShareEnabled(PK), false);
});
test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => {
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: true }), { action: "publish" });
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: false }), { action: "none" });
assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: false, eligible: true }), { action: "none" });
assert.deepEqual(resolvePublishSync({ featureOn: false, autoShare: true, eligible: true }), { action: "none" });
});
+1
View File
@@ -37,6 +37,7 @@ test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () =>
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");
assert.equal(events["videos:published"]?.length, 1, "subscribes to videos:published");
initNip71MirrorSync(svc); // guard prevents double-registration
assert.equal(events["videos:edited"].length, 1, "no double-register");