mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
0c92a259c5
commit
783c7df0b7
4 files changed
+226
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user