mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #372 from PR0M3TH3AN/codex/add-boost-on-nostr-menu-options
Add manual Nostr boost menu actions
This commit is contained in:
12 files changed
+1429
-32
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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() : "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user