mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
115521928c
commit
685e52656b
6 files changed
+149
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user