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
|
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
|
||||||
FEATURE_WATCH_HISTORY_V2: true,
|
FEATURE_WATCH_HISTORY_V2: true,
|
||||||
FEATURE_PUBLISH_NIP71: false,
|
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_HASHTAG_PREFERENCES: false,
|
||||||
FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE),
|
FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE),
|
||||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
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,
|
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||||
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
|
||||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
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_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
|
||||||
FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS,
|
FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS,
|
||||||
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
||||||
@@ -233,6 +237,11 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
|
|||||||
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
|
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(
|
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
|
||||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
|
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
|
||||||
DEFAULT_FLAGS.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 { collapseUserVideos } from "./myVideosData.js";
|
||||||
import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js";
|
import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js";
|
||||||
import { reconcileStorage } from "./myVideosReconcile.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
|
// Maps a health severity to a status-pill style, mirroring
|
||||||
// ProfileRelayController.applyStatusPill so the look stays consistent.
|
// ProfileRelayController.applyStatusPill so the look stays consistent.
|
||||||
@@ -369,6 +383,10 @@ export class MyVideosController {
|
|||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
this.buildActionButton("Edit", () => this.handleEdit(video)),
|
this.buildActionButton("Edit", () => this.handleEdit(video)),
|
||||||
);
|
);
|
||||||
|
const mirrorBtn = this.buildMirrorButton(video);
|
||||||
|
if (mirrorBtn) {
|
||||||
|
actions.appendChild(mirrorBtn);
|
||||||
|
}
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
this.buildActionButton("Delete", () => this.handleDelete(video), true),
|
this.buildActionButton("Delete", () => this.handleDelete(video), true),
|
||||||
);
|
);
|
||||||
@@ -376,6 +394,72 @@ export class MyVideosController {
|
|||||||
return actions;
|
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) {
|
buildActionButton(label, onClick, danger = false) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "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