Merge pull request #372 from PR0M3TH3AN/codex/add-boost-on-nostr-menu-options

Add manual Nostr boost menu actions
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-09 09:40:11 -04:00
commit e05006f0b7
12 files changed
+1429 -32

No files matched your search

+27
View File
@@ -196,6 +196,33 @@
>
Copy link
</button>
<div
class="px-4 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
>
Boost on Nostr…
</div>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="repost-event"
data-context="modal"
>
Repost (kind 6)
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="mirror-video"
data-context="modal"
>
Mirror (kind 1063)
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="ensure-presence"
data-context="modal"
>
Rebroadcast
</button>
<div class="my-1 border-t border-gray-700 opacity-70"></div>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
+1 -2
View File
@@ -240,8 +240,7 @@ export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90;
export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Cooldown window (in seconds) between manual "Ensure presence" rebroadcast
* attempts.
* Cooldown window (in seconds) between manual "Rebroadcast" attempts.
*
* Rebroadcasting is an escape hatch for creators to nudge stale relays. A
* short cooldown keeps this safeguard from turning into an accidental DDoS.
+13 -1
View File
@@ -169,7 +169,7 @@ header img {
.video-card {
background-color: var(--color-card);
border-radius: 0.75rem;
overflow: hidden;
overflow: visible;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-md);
}
@@ -585,6 +585,9 @@ header img {
position: relative;
padding-top: 56.25%;
background-color: #0f172a;
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.video-card img.thumbnail {
@@ -1574,6 +1577,15 @@ footer a:hover {
object-fit: cover;
}
.video-card .ratio-16-9 {
border-radius: 0.75rem;
overflow: hidden;
}
.video-card .ratio-16-9 > [data-video-thumbnail] {
border-radius: inherit;
}
/* Sidebar default states */
#sidebarContainer {
position: relative;
+3 -1
View File
@@ -15,6 +15,7 @@ import {
getNostrEventSchema,
setNostrEventSchemaOverrides,
buildVideoPostEvent,
buildRepostEvent,
} from "./nostrEventSchemas.js";
// Inspect the current schema
@@ -61,7 +62,7 @@ const { video, rawEvent } = await nostrClient.getEventById(eventId, {
In DevTools the active client lives at `window.bitvidApp?.nostrClient`.
Use the `rawEvent` blob when implementing “Ensure presence” style flows so the
Use the `rawEvent` blob when implementing “Rebroadcast” style flows so the
client can republish exactly what was signed, including the original
`sig`. Republishers should prefer `rawEvent.sig` over re-signing unless the
payload actually changes; this avoids accidental drift in dedupe tags or
@@ -73,6 +74,7 @@ timestamps.
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
+71 -3
View File
@@ -36,6 +36,19 @@ function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i;
function isHexPubkey(value) {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim();
if (!trimmed) {
return false;
}
return HEX_PUBKEY_REGEX.test(trimmed);
}
function dedupeNpubs(values) {
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
return Array.from(
@@ -238,17 +251,67 @@ function encodeHexToNpub(hex) {
}
}
function normalizeParticipantTagValue(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const nip19 = window?.NostrTools?.nip19;
const lower = trimmed.toLowerCase();
if (lower.startsWith("npub")) {
if (nip19?.decode) {
try {
const decoded = nip19.decode(trimmed);
if (decoded?.type === "npub") {
return trimmed;
}
} catch (error) {
// Swallow decode errors so we can still fall back to the raw npub string.
}
}
return trimmed;
}
if (isHexPubkey(trimmed)) {
const encoded = encodeHexToNpub(trimmed);
if (encoded) {
return encoded;
}
if (!canDecodeNpub()) {
return trimmed;
}
}
if (nip19?.decode) {
try {
const decoded = nip19.decode(trimmed);
if (decoded?.type === "npub") {
return trimmed;
}
} catch (error) {
// Ignore decode failures; values that cannot be interpreted as npubs are dropped.
}
}
return "";
}
function extractNpubsFromEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
const hexKeys = event.tags
const candidates = event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
const npubs = hexKeys
.map((hex) => encodeHexToNpub(hex))
const npubs = candidates
.map((value) => normalizeParticipantTagValue(value))
.filter((npub) => typeof npub === "string" && npub);
return dedupeNpubs(npubs);
@@ -570,3 +633,8 @@ export async function persistAdminState(actorNpub, updates) {
return persistNostrState(actorNpub, updates);
}
export const __adminListStoreTestHooks = Object.freeze({
extractNpubsFromEvent,
normalizeParticipantTagValue,
});
+326 -11
View File
@@ -1778,13 +1778,36 @@ class Application {
const nip07Button = document.getElementById("loginNIP07");
if (nip07Button) {
const originalLabel = nip07Button.textContent;
let slowExtensionTimer = null;
const slowExtensionDelayMs = 8_000;
const clearSlowExtensionTimer = () => {
if (slowExtensionTimer) {
clearTimeout(slowExtensionTimer);
slowExtensionTimer = null;
}
};
const setLoadingState = (isLoading) => {
nip07Button.disabled = isLoading;
nip07Button.dataset.loading = isLoading ? "true" : "false";
nip07Button.setAttribute("aria-busy", isLoading ? "true" : "false");
nip07Button.textContent = isLoading
? "Connecting to NIP-07 extension..."
: originalLabel;
if (isLoading) {
nip07Button.disabled = true;
nip07Button.dataset.loading = "true";
nip07Button.setAttribute("aria-busy", "true");
nip07Button.textContent = "Connecting to NIP-07 extension...";
clearSlowExtensionTimer();
slowExtensionTimer = window.setTimeout(() => {
if (nip07Button.dataset.loading === "true") {
nip07Button.textContent = "Waiting for the extension prompt…";
}
}, slowExtensionDelayMs);
} else {
nip07Button.disabled = false;
nip07Button.dataset.loading = "false";
nip07Button.setAttribute("aria-busy", "false");
clearSlowExtensionTimer();
nip07Button.textContent = originalLabel;
}
};
nip07Button.addEventListener("click", async () => {
@@ -4916,6 +4939,37 @@ class Application {
});
}
derivePointerFromDataset(dataset = {}, context = "") {
const type =
typeof dataset.pointerType === "string" ? dataset.pointerType.trim() : "";
const value =
typeof dataset.pointerValue === "string" ? dataset.pointerValue.trim() : "";
const relay =
typeof dataset.pointerRelay === "string" ? dataset.pointerRelay.trim() : "";
if (type && value) {
return relay ? [type, value, relay] : [type, value];
}
if (
context === "modal" &&
Array.isArray(this.currentVideoPointer) &&
this.currentVideoPointer.length >= 2
) {
return this.currentVideoPointer;
}
if (
context === "modal" &&
Array.isArray(this.currentVideo?.pointer) &&
this.currentVideo.pointer.length >= 2
) {
return this.currentVideo.pointer;
}
return null;
}
async handleMoreMenuAction(action, dataset = {}) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
@@ -4959,6 +5013,14 @@ class Application {
await this.handleRemoveHistoryAction(dataset);
break;
}
case "repost-event": {
await this.handleRepostAction(dataset);
break;
}
case "mirror-video": {
await this.handleMirrorAction(dataset);
break;
}
case "ensure-presence": {
await this.handleEnsurePresenceAction(dataset);
break;
@@ -5158,6 +5220,259 @@ class Application {
}
}
async handleRepostAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const fallbackEventId =
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.showError("No event is available to repost.");
return;
}
const pointer = this.derivePointerFromDataset(dataset, context);
let author = typeof dataset.author === "string" ? dataset.author.trim() : "";
if (!author && context === "modal" && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
const rawKindValue = (() => {
if (typeof dataset.kind === "string" && dataset.kind.trim()) {
const parsed = Number.parseInt(dataset.kind.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
if (Number.isFinite(dataset.kind)) {
return Number(dataset.kind);
}
if (Number.isFinite(this.currentVideo?.kind)) {
return Number(this.currentVideo.kind);
}
return null;
})();
const options = {
pointer,
pointerType: dataset.pointerType,
pointerValue: dataset.pointerValue,
pointerRelay: dataset.pointerRelay,
authorPubkey: author,
};
if (Number.isFinite(rawKindValue)) {
options.kind = Math.floor(rawKindValue);
}
try {
const result = await nostrClient.repostEvent(targetEventId, options);
if (!result?.ok) {
const code = result?.error || "repost-failed";
switch (code) {
case "invalid-event-id":
this.showError("No event is available to repost.");
break;
case "missing-actor":
this.showError(
"Cannot sign the repost right now. Please refresh and try again.",
);
break;
case "pool-unavailable":
this.showError("Cannot reach relays right now. Please try again later.");
break;
case "publish-rejected":
this.showError("No relay accepted the repost attempt.");
break;
case "signing-failed":
this.showError("Failed to sign the repost. Please try again.");
break;
default:
this.showError("Failed to repost the video. Please try again later.");
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Reposted to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
);
} else {
fragments.push("Reposted.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.showSuccess(fragments.join(" ").trim());
} catch (error) {
if (isDevMode) {
console.warn("[app] Repost action failed:", error);
}
this.showError("Failed to repost the video. Please try again later.");
}
}
async handleMirrorAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
typeof dataset.eventId === "string" && dataset.eventId.trim()
? dataset.eventId.trim()
: "";
const fallbackEventId =
context === "modal" && this.currentVideo?.id ? this.currentVideo.id : "";
const targetEventId = explicitEventId || fallbackEventId;
if (!targetEventId) {
this.showError("No event is available to mirror.");
return;
}
const explicitUrl =
typeof dataset.url === "string" && dataset.url.trim() ? dataset.url.trim() : "";
const fallbackUrl =
context === "modal" && typeof this.currentVideo?.url === "string"
? this.currentVideo.url.trim()
: "";
const targetUrl = explicitUrl || fallbackUrl;
if (!targetUrl) {
this.showError("This video does not expose a hosted URL to mirror.");
return;
}
const rawMagnet =
typeof dataset.magnet === "string" && dataset.magnet.trim()
? dataset.magnet.trim()
: "";
const fallbackMagnet =
context === "modal"
? (this.currentVideo?.magnet || this.currentVideo?.originalMagnet || "")
: "";
const magnet = rawMagnet || fallbackMagnet;
const thumbnail =
typeof dataset.thumbnail === "string" && dataset.thumbnail.trim()
? dataset.thumbnail.trim()
: context === "modal" && typeof this.currentVideo?.thumbnail === "string"
? this.currentVideo.thumbnail.trim()
: "";
const description =
typeof dataset.description === "string" && dataset.description.trim()
? dataset.description.trim()
: context === "modal" && typeof this.currentVideo?.description === "string"
? this.currentVideo.description.trim()
: "";
const title =
typeof dataset.title === "string" && dataset.title.trim()
? dataset.title.trim()
: context === "modal" && typeof this.currentVideo?.title === "string"
? this.currentVideo.title.trim()
: "";
const datasetPrivate =
dataset.isPrivate === "true" || dataset.isPrivate === true ? true : false;
const fallbackPrivate = context === "modal" && this.currentVideo?.isPrivate === true;
const isPrivate = datasetPrivate || fallbackPrivate;
if (isPrivate) {
this.showError("Mirroring is unavailable for private videos.");
return;
}
const options = {
url: targetUrl,
magnet,
thumbnail,
description,
title,
isPrivate,
};
try {
const result = await nostrClient.mirrorVideoEvent(targetEventId, options);
if (!result?.ok) {
const code = result?.error || "mirror-failed";
switch (code) {
case "invalid-event-id":
this.showError("No event is available to mirror.");
break;
case "missing-url":
this.showError("This video does not expose a hosted URL to mirror.");
break;
case "missing-actor":
this.showError(
"Cannot sign the mirror right now. Please refresh and try again.",
);
break;
case "pool-unavailable":
this.showError("Cannot reach relays right now. Please try again later.");
break;
case "publish-rejected":
this.showError("No relay accepted the mirror attempt.");
break;
case "signing-failed":
this.showError("Failed to sign the mirror. Please try again.");
break;
default:
this.showError("Failed to mirror the video. Please try again later.");
break;
}
return;
}
const acceptedCount = Array.isArray(result.summary?.accepted)
? result.summary.accepted.length
: 0;
const relayCount =
acceptedCount > 0
? acceptedCount
: Array.isArray(result.relays)
? result.relays.length
: acceptedCount;
const fragments = [];
if (relayCount > 0) {
fragments.push(
`Mirrored to ${relayCount} relay${relayCount === 1 ? "" : "s"}.`,
);
} else {
fragments.push("Mirrored.");
}
if (result.sessionActor) {
fragments.push("Boost as session user.");
}
this.showSuccess(fragments.join(" ").trim());
} catch (error) {
if (isDevMode) {
console.warn("[app] Mirror action failed:", error);
}
this.showError("Failed to mirror the video. Please try again later.");
}
}
async handleEnsurePresenceAction(dataset = {}) {
const context = typeof dataset.context === "string" ? dataset.context : "";
const explicitEventId =
@@ -5197,8 +5512,8 @@ class Application {
const remainingSeconds = Math.ceil(remainingMs / 1000);
const message =
remainingSeconds > 0
? `Ensure presence is cooling down. Try again in ${remainingSeconds}s.`
: "Ensure presence is cooling down. Try again soon.";
? `Rebroadcast is cooling down. Try again in ${remainingSeconds}s.`
: "Rebroadcast is cooling down. Try again soon.";
this.showStatus(message);
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
window.setTimeout(() => {
@@ -5221,7 +5536,7 @@ class Application {
this.showError("Cannot reach relays right now. Please try again later.");
break;
default:
this.showError("Failed to ensure presence. Please try again later.");
this.showError("Failed to rebroadcast. Please try again later.");
break;
}
return;
@@ -5235,9 +5550,9 @@ class Application {
this.showSuccess("Rebroadcast requested across relays.");
} catch (error) {
if (isDevMode) {
console.warn("[app] Ensure presence action failed:", error);
console.warn("[app] Rebroadcast action failed:", error);
}
this.showError("Failed to ensure presence. Please try again later.");
this.showError("Failed to rebroadcast. Please try again later.");
}
}
+477 -7
View File
@@ -27,6 +27,7 @@ import { extractMagnetHints } from "./magnet.js";
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildViewEvent,
buildWatchHistoryIndexEvent,
buildWatchHistoryChunkEvent,
@@ -57,9 +58,9 @@ const RELAY_URLS = Array.from(DEFAULT_RELAY_URLS);
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
const NIP07_LOGIN_TIMEOUT_MS = 60_000; // 60 seconds
const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again.";
"Timed out waiting for the NIP-07 extension. Confirm the extension prompt in your browser toolbar and try again.";
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
@@ -378,8 +379,26 @@ async function runNip07WithRetry(
retryMultiplier = 2,
} = {},
) {
let hasStarted = false;
let cachedPromise = null;
const getOrStartOperation = () => {
if (!hasStarted) {
hasStarted = true;
try {
cachedPromise = Promise.resolve(operation());
} catch (error) {
hasStarted = false;
cachedPromise = null;
throw error;
}
}
return cachedPromise;
};
try {
return await withNip07Timeout(operation, {
return await withNip07Timeout(getOrStartOperation, {
timeoutMs,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
@@ -399,11 +418,11 @@ async function runNip07WithRetry(
if (isDevMode) {
console.warn(
`[nostr] ${label} timed out after ${timeoutMs}ms. Retrying once with ${extendedTimeout}ms timeout.`,
`[nostr] ${label} taking longer than ${timeoutMs}ms. Waiting up to ${extendedTimeout}ms for extension response.`,
);
}
return withNip07Timeout(operation, {
return withNip07Timeout(getOrStartOperation, {
timeoutMs: extendedTimeout,
message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
});
@@ -5464,6 +5483,7 @@ export class NostrClient {
logName = context,
devLogLabel = logName,
rejectionLogLevel = "error",
relaysOverride = null,
} = {}
) {
const extension = window?.nostr;
@@ -5549,9 +5569,21 @@ export class NostrClient {
console.log(`Signed ${devLogLabel} event:`, signedEvent);
}
let targetRelays = sanitizeRelayList(
Array.isArray(relaysOverride) && relaysOverride.length
? relaysOverride
: Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: this.relays
);
if (!targetRelays.length) {
targetRelays = Array.from(RELAY_URLS);
}
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
targetRelays,
signedEvent
);
@@ -5594,7 +5626,12 @@ export class NostrClient {
});
}
return { signedEvent, summary: publishSummary, signerPubkey };
return {
signedEvent,
summary: publishSummary,
signerPubkey,
relays: targetRelays,
};
}
async publishVideo(videoPayload, pubkey) {
@@ -7080,6 +7117,439 @@ export class NostrClient {
return video;
}
async repostEvent(eventId, options = {}) {
const normalizedId =
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
if (!normalizedId) {
return { ok: false, error: "invalid-event-id" };
}
let pointer = null;
if (options.pointer) {
pointer = normalizePointerInput(options.pointer);
}
if (!pointer) {
const type =
typeof options.pointerType === "string" ? options.pointerType.trim() : "";
const value =
typeof options.pointerValue === "string" ? options.pointerValue.trim() : "";
if (type && value) {
const candidate = [type, value];
const relay =
typeof options.pointerRelay === "string"
? options.pointerRelay.trim()
: "";
if (relay) {
candidate.push(relay);
}
pointer = normalizePointerInput(candidate);
}
}
const cachedVideo = this.allEvents.get(normalizedId) || null;
const cachedRaw = this.rawEvents.get(normalizedId) || null;
let authorPubkey =
typeof options.authorPubkey === "string" && options.authorPubkey.trim()
? options.authorPubkey.trim().toLowerCase()
: "";
if (!authorPubkey && cachedVideo?.pubkey) {
authorPubkey = cachedVideo.pubkey.trim().toLowerCase();
}
if (!authorPubkey && cachedRaw?.pubkey) {
authorPubkey = cachedRaw.pubkey.trim().toLowerCase();
}
let address = typeof options.address === "string" ? options.address.trim() : "";
if (!address && pointer?.type === "a") {
address = pointer.value;
}
let addressRelay =
typeof options.addressRelay === "string" ? options.addressRelay.trim() : "";
if (!addressRelay && pointer?.type === "a" && pointer.relay) {
addressRelay = pointer.relay;
}
let eventRelay =
typeof options.eventRelay === "string" ? options.eventRelay.trim() : "";
if (!eventRelay && pointer?.type === "e" && pointer.relay) {
eventRelay = pointer.relay;
}
let targetKind = Number.isFinite(options.kind)
? Math.floor(options.kind)
: null;
const parseAddressMetadata = (candidate) => {
if (typeof candidate !== "string" || !candidate) {
return;
}
const parts = candidate.split(":");
if (parts.length >= 3) {
const maybeKind = Number.parseInt(parts[0], 10);
if (Number.isFinite(maybeKind) && !Number.isFinite(targetKind)) {
targetKind = maybeKind;
}
const maybePubkey = parts[1];
if (
maybePubkey &&
!authorPubkey &&
/^[0-9a-f]{64}$/i.test(maybePubkey)
) {
authorPubkey = maybePubkey.toLowerCase();
}
}
};
if (address) {
parseAddressMetadata(address);
}
if (!Number.isFinite(targetKind)) {
if (Number.isFinite(cachedRaw?.kind)) {
targetKind = Math.floor(cachedRaw.kind);
} else if (Number.isFinite(cachedVideo?.kind)) {
targetKind = Math.floor(cachedVideo.kind);
} else {
targetKind = 30078;
}
}
const deriveIdentifierFromVideo = () => {
if (!cachedVideo || typeof cachedVideo !== "object") {
return "";
}
if (typeof cachedVideo.videoRootId === "string" && cachedVideo.videoRootId.trim()) {
return cachedVideo.videoRootId.trim();
}
if (Array.isArray(cachedVideo.tags)) {
for (const tag of cachedVideo.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string" && tag[1].trim()) {
return tag[1].trim();
}
}
}
return "";
};
if (!address) {
const identifier = deriveIdentifierFromVideo();
const ownerPubkey =
authorPubkey ||
(cachedVideo?.pubkey ? cachedVideo.pubkey.trim().toLowerCase() : "") ||
(cachedRaw?.pubkey ? cachedRaw.pubkey.trim().toLowerCase() : "");
if (identifier && ownerPubkey) {
address = `${targetKind}:${ownerPubkey}:${identifier}`;
parseAddressMetadata(address);
} else if (cachedRaw) {
const fallbackAddress = eventToAddressPointer(cachedRaw);
if (fallbackAddress) {
address = fallbackAddress;
parseAddressMetadata(address);
}
}
}
const relaysOverride = sanitizeRelayList(
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: this.relays
);
const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS);
let actorPubkey =
typeof options.actorPubkey === "string" && options.actorPubkey.trim()
? options.actorPubkey.trim()
: typeof this.pubkey === "string" && this.pubkey.trim()
? this.pubkey.trim()
: "";
if (!actorPubkey) {
try {
const ensured = await this.ensureSessionActor();
actorPubkey = ensured || "";
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to ensure session actor before repost:", error);
}
return { ok: false, error: "missing-actor", details: error };
}
}
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
if (!this.pool) {
try {
await this.ensurePool();
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to ensure pool before repost:", error);
}
return { ok: false, error: "pool-unavailable", details: error };
}
}
const createdAt =
typeof options.created_at === "number" && Number.isFinite(options.created_at)
? Math.max(0, Math.floor(options.created_at))
: Math.floor(Date.now() / 1000);
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2)
: [];
const repostEvent = buildRepostEvent({
pubkey: actorPubkey,
created_at: createdAt,
eventId: normalizedId,
eventRelay,
address,
addressRelay,
authorPubkey,
additionalTags,
});
try {
const { signedEvent, summary, signerPubkey } = await this.signAndPublishEvent(
repostEvent,
{
context: "repost",
logName: "Repost",
devLogLabel: "repost",
relaysOverride: relays,
}
);
const normalizedSigner =
typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : "";
const normalizedLogged =
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
const sessionPubkey =
typeof this.sessionActor?.pubkey === "string"
? this.sessionActor.pubkey.toLowerCase()
: "";
const usedSessionActor =
normalizedSigner &&
normalizedSigner !== normalizedLogged &&
normalizedSigner === sessionPubkey;
return {
ok: true,
event: signedEvent,
summary,
relays,
sessionActor: usedSessionActor,
signerPubkey,
};
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Repost publish failed:", error);
}
const relayFailure =
error && typeof error === "object" && Array.isArray(error.relayFailures);
return {
ok: false,
error: relayFailure ? "publish-rejected" : "signing-failed",
details: error,
};
}
}
async mirrorVideoEvent(eventId, options = {}) {
const normalizedId =
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
if (!normalizedId) {
return { ok: false, error: "invalid-event-id" };
}
const cachedVideo = this.allEvents.get(normalizedId) || null;
const sanitize = (value) => (typeof value === "string" ? value.trim() : "");
let url = sanitize(options.url);
if (!url && cachedVideo?.url) {
url = sanitize(cachedVideo.url);
}
if (!url) {
return { ok: false, error: "missing-url" };
}
const isPrivate =
options.isPrivate === true ||
options.isPrivate === "true" ||
cachedVideo?.isPrivate === true;
let magnet = sanitize(options.magnet);
if (!magnet && cachedVideo?.magnet) {
magnet = sanitize(cachedVideo.magnet);
}
if (!magnet && cachedVideo?.rawMagnet) {
magnet = sanitize(cachedVideo.rawMagnet);
}
if (!magnet && cachedVideo?.originalMagnet) {
magnet = sanitize(cachedVideo.originalMagnet);
}
let thumbnail = sanitize(options.thumbnail);
if (!thumbnail && cachedVideo?.thumbnail) {
thumbnail = sanitize(cachedVideo.thumbnail);
}
let description = sanitize(options.description);
if (!description && cachedVideo?.description) {
description = sanitize(cachedVideo.description);
}
let title = sanitize(options.title);
if (!title && cachedVideo?.title) {
title = sanitize(cachedVideo.title);
}
const providedMimeType = sanitize(options.mimeType);
const inferredMimeType = inferMimeTypeFromUrl(url);
const mimeType = providedMimeType || inferredMimeType || "application/octet-stream";
const explicitAlt = sanitize(options.altText);
const altText = explicitAlt || description || title || "";
const tags = [];
tags.push(["url", url]);
if (mimeType) {
tags.push(["m", mimeType]);
}
if (thumbnail) {
tags.push(["thumb", thumbnail]);
}
if (altText) {
tags.push(["alt", altText]);
}
if (!isPrivate && magnet) {
tags.push(["magnet", magnet]);
}
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2)
: [];
tags.push(...additionalTags);
let actorPubkey =
typeof options.actorPubkey === "string" && options.actorPubkey.trim()
? options.actorPubkey.trim()
: typeof this.pubkey === "string" && this.pubkey.trim()
? this.pubkey.trim()
: "";
if (!actorPubkey) {
try {
const ensured = await this.ensureSessionActor();
actorPubkey = ensured || "";
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to ensure session actor before mirror:", error);
}
return { ok: false, error: "missing-actor", details: error };
}
}
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
if (!this.pool) {
try {
await this.ensurePool();
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Failed to ensure pool before mirror:", error);
}
return { ok: false, error: "pool-unavailable", details: error };
}
}
const createdAt =
typeof options.created_at === "number" && Number.isFinite(options.created_at)
? Math.max(0, Math.floor(options.created_at))
: Math.floor(Date.now() / 1000);
const relaysOverride = sanitizeRelayList(
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: this.relays
);
const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS);
const mirrorEvent = buildVideoMirrorEvent({
pubkey: actorPubkey,
created_at: createdAt,
tags,
content: altText,
});
try {
const { signedEvent, summary, signerPubkey } = await this.signAndPublishEvent(
mirrorEvent,
{
context: "mirror",
logName: "NIP-94 mirror",
devLogLabel: "NIP-94 mirror",
rejectionLogLevel: "warn",
relaysOverride: relays,
}
);
const normalizedSigner =
typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : "";
const normalizedLogged =
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
const sessionPubkey =
typeof this.sessionActor?.pubkey === "string"
? this.sessionActor.pubkey.toLowerCase()
: "";
const usedSessionActor =
normalizedSigner &&
normalizedSigner !== normalizedLogged &&
normalizedSigner === sessionPubkey;
return {
ok: true,
event: signedEvent,
summary,
relays,
sessionActor: usedSessionActor,
signerPubkey,
};
} catch (error) {
if (isDevMode) {
console.warn("[nostr] Mirror publish failed:", error);
}
const relayFailure =
error && typeof error === "object" && Array.isArray(error.relayFailures);
return {
ok: false,
error: relayFailure ? "publish-rejected" : "signing-failed",
details: error,
};
}
}
async rebroadcastEvent(eventId, options = {}) {
const normalizedId =
typeof eventId === "string" && eventId.trim() ? eventId.trim() : "";
+69
View File
@@ -10,6 +10,7 @@ export const NOTE_TYPES = Object.freeze({
VIDEO_MIRROR: "videoMirror",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
WATCH_HISTORY_INDEX: "watchHistoryIndex",
@@ -133,6 +134,16 @@ const BASE_SCHEMAS = {
description: "Optional alt text carried alongside hosted URL metadata.",
},
},
[NOTE_TYPES.REPOST]: {
type: NOTE_TYPES.REPOST,
label: "NIP-18 repost",
kind: 6,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Content field intentionally empty for pure repost events.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
type: NOTE_TYPES.NIP71_VIDEO,
label: "NIP-71 video (normal)",
@@ -428,6 +439,64 @@ export function buildVideoMirrorEvent({
};
}
export function buildRepostEvent({
pubkey,
created_at,
eventId = "",
eventRelay = "",
address = "",
addressRelay = "",
authorPubkey = "",
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const tags = [];
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
const normalizedEventRelay = typeof eventRelay === "string" ? eventRelay.trim() : "";
if (normalizedEventId) {
if (normalizedEventRelay) {
tags.push(["e", normalizedEventId, normalizedEventRelay]);
} else {
tags.push(["e", normalizedEventId]);
}
}
const normalizedAddress = typeof address === "string" ? address.trim() : "";
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
if (normalizedAddress) {
if (normalizedAddressRelay) {
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
} else {
tags.push(["a", normalizedAddress]);
}
}
const normalizedAuthorPubkey =
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
if (normalizedAuthorPubkey) {
tags.push(["p", normalizedAuthorPubkey]);
}
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 6,
pubkey,
created_at,
tags,
content: "",
};
}
export function buildRelayListEvent({
pubkey,
created_at,
+101 -7
View File
@@ -198,7 +198,6 @@ export class VideoCard {
"video-card",
"bg-gray-900",
"rounded-lg",
"overflow-hidden",
"shadow-lg",
"hover:shadow-2xl",
"transition-all",
@@ -216,7 +215,14 @@ export class VideoCard {
this.applySourceDatasets();
const anchor = this.createElement("a", {
classNames: ["block", "cursor-pointer", "relative", "group"],
classNames: [
"block",
"cursor-pointer",
"relative",
"group",
"rounded-t-lg",
"overflow-hidden",
],
attrs: {
href: this.shareUrl,
},
@@ -722,17 +728,105 @@ export class VideoCard {
eventId: this.video.id || "",
}, ["text-gray-100", "hover:bg-gray-700"]);
const pointer = Array.isArray(this.pointerInfo?.pointer)
? this.pointerInfo.pointer
: null;
const pointerType = pointer && pointer.length >= 2 ? pointer[0] : "";
const pointerValue = pointer && pointer.length >= 2 ? pointer[1] : "";
const pointerRelay = pointer && pointer.length >= 3 ? pointer[2] || "" : "";
const numericKind =
Number.isFinite(this.video.kind) && this.video.kind > 0
? Math.floor(this.video.kind)
: null;
const baseBoostDataset = {
eventId: this.video.id || "",
author: this.video.pubkey || "",
};
if (pointerType && pointerValue) {
baseBoostDataset.pointerType = pointerType;
baseBoostDataset.pointerValue = pointerValue;
}
if (pointerRelay) {
baseBoostDataset.pointerRelay = pointerRelay;
}
if (Number.isFinite(numericKind)) {
baseBoostDataset.kind = String(numericKind);
}
const boostLabel = this.createElement("div", {
classNames: [
"px-4",
"pt-2",
"pb-1",
"text-xs",
"font-semibold",
"uppercase",
"tracking-wide",
"text-gray-400",
],
textContent: "Boost on Nostr…",
});
list.appendChild(boostLabel);
addActionButton(
"Repost (kind 6)",
"repost-event",
baseBoostDataset,
["text-gray-100", "hover:bg-gray-700"],
);
if (this.playbackUrl && this.video.isPrivate !== true) {
const mirrorDataset = {
...baseBoostDataset,
url: this.playbackUrl,
magnet: this.playbackMagnet || "",
thumbnail: typeof this.video.thumbnail === "string" ? this.video.thumbnail : "",
description:
typeof this.video.description === "string" ? this.video.description : "",
title: typeof this.video.title === "string" ? this.video.title : "",
isPrivate: this.video.isPrivate === true ? "true" : "false",
};
addActionButton(
"Mirror (kind 1063)",
"mirror-video",
mirrorDataset,
["text-gray-100", "hover:bg-gray-700"],
);
}
const ensureDataset = {
...baseBoostDataset,
pubkey: this.video.pubkey || "",
};
addActionButton(
"Rebroadcast",
"ensure-presence",
ensureDataset,
["text-gray-100", "hover:bg-gray-700"],
);
list.appendChild(
this.createElement("div", {
classNames: ["my-1", "border-t", "border-gray-700", "opacity-70"],
})
);
if (this.pointerInfo && this.pointerInfo.key && this.pointerInfo.pointer) {
const [pointerType, pointerValue, pointerRelay] = this.pointerInfo.pointer;
if (pointerType && pointerValue) {
const [historyPointerType, historyPointerValue, historyPointerRelay] =
this.pointerInfo.pointer;
if (historyPointerType && historyPointerValue) {
const removeButton = addActionButton(
"Remove from history",
"remove-history",
{
pointerKey: this.pointerInfo.key,
pointerType,
pointerValue,
pointerRelay: pointerRelay || "",
pointerType: historyPointerType,
pointerValue: historyPointerValue,
pointerRelay: historyPointerRelay || "",
reason: "remove-item",
},
["text-gray-100", "hover:bg-gray-700"]
+106
View File
@@ -1028,6 +1028,112 @@ export class VideoModal {
return;
}
if (action === "repost-event") {
if (currentVideo?.id) {
button.dataset.eventId = currentVideo.id;
} else {
delete button.dataset.eventId;
}
if (currentVideo?.pubkey) {
button.dataset.author = currentVideo.pubkey;
} else {
delete button.dataset.author;
}
if (
Array.isArray(currentVideo?.pointer) &&
currentVideo.pointer.length >= 2
) {
const [pointerType, pointerValue, pointerRelay] = currentVideo.pointer;
button.dataset.pointerType = pointerType || "";
button.dataset.pointerValue = pointerValue || "";
if (pointerRelay) {
button.dataset.pointerRelay = pointerRelay;
} else {
delete button.dataset.pointerRelay;
}
} else {
delete button.dataset.pointerType;
delete button.dataset.pointerValue;
delete button.dataset.pointerRelay;
}
if (Number.isFinite(currentVideo?.kind)) {
button.dataset.kind = String(Math.floor(currentVideo.kind));
} else {
delete button.dataset.kind;
}
return;
}
if (action === "mirror-video") {
const hasUrl =
typeof currentVideo?.url === "string" && currentVideo.url.trim();
const isPrivate = currentVideo?.isPrivate === true;
if (hasUrl && !isPrivate) {
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
button.dataset.eventId = currentVideo.id || "";
button.dataset.author = currentVideo.pubkey || "";
button.dataset.url = currentVideo.url || "";
button.dataset.magnet =
currentVideo.magnet || currentVideo.originalMagnet || "";
button.dataset.thumbnail = currentVideo.thumbnail || "";
button.dataset.description = currentVideo.description || "";
button.dataset.title = currentVideo.title || "";
button.dataset.isPrivate = "false";
} else {
delete button.dataset.eventId;
delete button.dataset.author;
delete button.dataset.url;
delete button.dataset.magnet;
delete button.dataset.thumbnail;
delete button.dataset.description;
delete button.dataset.title;
button.dataset.isPrivate = "true";
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "ensure-presence") {
if (currentVideo?.id) {
button.dataset.eventId = currentVideo.id;
} else {
delete button.dataset.eventId;
}
if (currentVideo?.pubkey) {
button.dataset.author = currentVideo.pubkey;
button.dataset.pubkey = currentVideo.pubkey;
} else {
delete button.dataset.author;
delete button.dataset.pubkey;
}
if (
Array.isArray(currentVideo?.pointer) &&
currentVideo.pointer.length >= 2
) {
const [pointerType, pointerValue, pointerRelay] = currentVideo.pointer;
button.dataset.pointerType = pointerType || "";
button.dataset.pointerValue = pointerValue || "";
if (pointerRelay) {
button.dataset.pointerRelay = pointerRelay;
} else {
delete button.dataset.pointerRelay;
}
} else {
delete button.dataset.pointerType;
delete button.dataset.pointerValue;
delete button.dataset.pointerRelay;
}
return;
}
if (action === "open-channel" || action === "block-author") {
if (currentVideo?.pubkey) {
button.dataset.author = currentVideo.pubkey;
+89
View File
@@ -0,0 +1,89 @@
// Run with: node tests/admin-list-store.test.mjs
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
const mockNip19 = {
decode(value) {
if (typeof value !== "string") {
throw new Error("invalid input");
}
if (value.trim().toLowerCase().startsWith("npub")) {
return { type: "npub", data: "mock" };
}
throw new Error("unsupported value");
},
npubEncode(hex) {
if (typeof hex !== "string") {
throw new Error("invalid hex");
}
const trimmed = hex.trim();
if (!trimmed) {
throw new Error("empty hex");
}
return `npub1${trimmed}`;
},
};
globalThis.window.NostrTools.nip19 = mockNip19;
const { __adminListStoreTestHooks } = await import("../js/adminListStore.js");
const { extractNpubsFromEvent, normalizeParticipantTagValue } =
__adminListStoreTestHooks;
const sampleHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const sampleNpub = "npub1existingparticipant";
const event = {
tags: [
["p", sampleHex],
["p", sampleNpub],
["p", " npub1existingparticipant "],
["p", ""],
["e", "irrelevant"],
],
};
const normalized = extractNpubsFromEvent(event);
assert.deepEqual(
normalized,
[
mockNip19.npubEncode(sampleHex),
sampleNpub,
],
"should return unique npubs from hex and npub participant tags"
);
mockNip19.decode = () => {
throw new Error("decode unavailable");
};
assert.equal(
normalizeParticipantTagValue(sampleNpub),
sampleNpub,
"should fall back to the raw npub when decode fails"
);
mockNip19.decode = undefined;
mockNip19.npubEncode = undefined;
globalThis.window.NostrTools.nip19 = mockNip19;
const fallbackHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
assert.equal(
normalizeParticipantTagValue(fallbackHex),
fallbackHex,
"should preserve hex when nip19 helpers are unavailable"
);
console.log("admin-list-store tests passed");
+146
View File
@@ -0,0 +1,146 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const { buildRepostEvent } = await import("../js/nostrEventSchemas.js");
const { NostrClient } = await import("../js/nostr.js");
function createClientHarness() {
const client = new NostrClient();
client.relays = ["wss://relay.example" ];
client.writeRelays = ["wss://relay.example" ];
client.pool = {};
client.ensurePool = async () => client.pool;
return client;
}
(function testBuildRepostEventIncludesPointerTags() {
const createdAt = 1_700_000_000;
const event = buildRepostEvent({
pubkey: "actorpubkey",
created_at: createdAt,
eventId: "event123",
eventRelay: "wss://origin",
address: "30078:deadbeef:identifier",
addressRelay: "wss://address",
authorPubkey: "deadbeef",
});
assert.equal(event.kind, 6);
assert.equal(event.pubkey, "actorpubkey");
assert.equal(event.created_at, createdAt);
assert.deepEqual(event.tags[0], ["e", "event123", "wss://origin"]);
assert.deepEqual(event.tags[1], ["a", "30078:deadbeef:identifier", "wss://address"]);
assert.deepEqual(event.tags[2], ["p", "deadbeef"]);
})();
await (async function testRepostEventUsesSessionActorAndDerivesAddress() {
const client = createClientHarness();
const eventId = "test-event";
const authorPubkey = "f".repeat(64);
const sessionPubkey = "a".repeat(64);
client.sessionActor = { pubkey: sessionPubkey, privateKey: "session-key" };
client.ensureSessionActor = async () => sessionPubkey;
client.signAndPublishEvent = async (event, opts) => {
return {
signedEvent: event,
summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] },
signerPubkey: sessionPubkey,
};
};
client.allEvents.set(eventId, {
id: eventId,
pubkey: authorPubkey,
videoRootId: "root-id",
title: "Video",
url: "https://cdn.example/video.mp4",
});
const result = await client.repostEvent(eventId, {
pointer: ["e", eventId, "wss://origin"],
authorPubkey,
});
assert.equal(result.ok, true);
assert.equal(result.sessionActor, true);
assert.equal(Array.isArray(result.summary.accepted), true);
const tags = result.event?.tags;
assert.ok(Array.isArray(tags), "Repost event should include tags");
assert.deepEqual(tags[0], ["e", eventId, "wss://origin"]);
assert.equal(
tags.some((tag) => Array.isArray(tag) && tag[0] === "a" && tag[1].startsWith("30078:")),
true,
"Repost event should include an address pointer"
);
assert.equal(
tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === authorPubkey),
true,
"Repost event should include a p tag"
);
})();
await (async function testMirrorEventIncludesHostedMetadata() {
const client = createClientHarness();
const eventId = "mirror-event";
const actorPubkey = "1".repeat(64);
client.pubkey = actorPubkey;
client.signAndPublishEvent = async (event, opts) => {
return {
signedEvent: event,
summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] },
signerPubkey: actorPubkey,
};
};
const result = await client.mirrorVideoEvent(eventId, {
url: "https://videos.example/video.mp4",
magnet: "magnet:?xt=urn:btih:abc",
thumbnail: "https://videos.example/thumb.jpg",
description: "Sample clip",
title: "Sample",
});
assert.equal(result.ok, true);
const tags = result.event?.tags;
assert.ok(Array.isArray(tags));
const tagValues = Object.fromEntries(tags.map((tag) => [tag[0], tag.slice(1)]));
assert.equal(tagValues.url[0], "https://videos.example/video.mp4");
assert.equal(tagValues.m[0], "video/mp4");
assert.equal(tagValues.thumb[0], "https://videos.example/thumb.jpg");
assert.equal(tagValues.alt[0], "Sample clip");
assert.equal(tagValues.magnet[0], "magnet:?xt=urn:btih:abc");
})();
await (async function testMirrorSkipsMagnetWhenPrivate() {
const client = createClientHarness();
const eventId = "private-mirror";
const actorPubkey = "2".repeat(64);
client.pubkey = actorPubkey;
client.signAndPublishEvent = async (event, opts) => {
return {
signedEvent: event,
summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] },
signerPubkey: actorPubkey,
};
};
const result = await client.mirrorVideoEvent(eventId, {
url: "https://videos.example/private.mp4",
magnet: "magnet:?xt=urn:btih:hidden",
isPrivate: true,
});
assert.equal(result.ok, true);
const tags = result.event?.tags || [];
assert.equal(
tags.some((tag) => Array.isArray(tag) && tag[0] === "magnet"),
false,
"Private mirrors must omit the magnet tag",
);
})();
console.log("nostr boost action tests passed");