diff --git a/components/profile-modal.html b/components/profile-modal.html index 51421cdf..1f19b708 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1028,6 +1028,10 @@ role="status" aria-live="polite" >

+ ", + "tags": [ + ["d", ""], + ["title", ""], + ["published_at", "<unix timestamp>"], + ["alt", "<description for accessibility>"], + + // video data + ["imeta", + "url https://example.com/media.mp4", + "m video/mp4", + "dim 480x480", + "blurhash eVF$^OI:${M{%LRjWBoLoLaeR*", + "image https://example.com/thumb.jpg", + "x 3093509d1e0bc604ff60cb9286f4cd7c781553bc8991937befaacfdc28ec5cdc" + ], + // audio data + ["imeta", + "url https://example.com/audio.mp3", + "x b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93", + "m audio/mp3", + "l en ISO-639-1 ov" + ], + + ["duration", <duration in seconds>], + ["content-warning", "<reason>"], + + // origin tracking for imported content + ["origin", "<platform>", "<external-id>", "<original-url>", "<optional-metadata>"], + + // participants + ["p", "<32-bytes hex of a pubkey>", "<optional recommended relay URL>"], + + // hashtags + ["t", "<tag>"], + ["t", "<tag>"], + + // reference links + ["r", "<url>"] + ] +} +``` + +## Referencing Addressable Events + +To reference an addressable video: + +``` +["a", "34235:<pubkey>:<d-tag-value>", "<relay-url>"] // for normal videos +["a", "34236:<pubkey>:<d-tag-value>", "<relay-url>"] // for short videos +``` diff --git a/docs/video-liveness-plan.md b/docs/video-liveness-plan.md new file mode 100644 index 00000000..22b28092 --- /dev/null +++ b/docs/video-liveness-plan.md @@ -0,0 +1,73 @@ +# Video Liveness / Card Health — Dev Plan + +Status: **planned** (investigated; not started). Branch: `unstable`. + +## Goal +Cards should only render videos that can actually play, decide fast, support +multiple source links (fall back if one is down), and never show two of the same +video (handled separately by cross-ecosystem dedup). Prefer CDN; if CDN is dead +but a WebTorrent source is live, keep the card. + +## What already works (don't rebuild) +- **Hide/show policy is correct** (`js/utils/cardSourceVisibility.js`): a card + hides only when **neither** CDN (`urlHealthState`) **nor** WebTorrent + (`streamHealthState`) is healthy (and neither is pending); it un-hides the + moment WebTorrent flips `healthy`. This already matches "no CDN → block; if a + webtorrent flips green → bring it back." +- Owner's own videos bypass hiding. + +## The real problems (diagnosis) +1. **Too slow.** `URL_PROBE_TIMEOUT_MS = 8s` (+ a retry), WebTorrent + `PROBE_TIMEOUT_MS = 20s`. While a probe is *pending*, the card is **shown** + (pending ≠ hidden). So unplayable videos sit visible for many seconds before + being hidden. → tune timeouts + concurrency for a faster verdict. +2. **Probe accuracy.** The CDN check races a `HEAD {mode:"no-cors"}` which is + **opaque** (status unreadable) for cross-origin hosts (most NIP-71 CDNs like + `relay.towardsliberty.com`). Opaque without `confirmPlayable` → `unknown` (not + healthy). Whether a card shows then hinges on timing/streamState. Net: foreign + CDNs are judged unreliably. → use the **video-element probe** (`confirmPlayable`, + actually loads the media) as the source of truth for the card verdict. +3. **Single source only.** The ingest adapter keeps ONE `url` (the primary imeta + variant) and drops the rest. NIP-71 events can list **multiple imeta** (mirrors + / alternate media). Example: Walker/THE Bitcoin Podcast event has a `video/mp4` + imeta AND an `audio/mpeg` imeta; other events may list multiple video mirrors. + → capture all playable sources and fall back across them. + +## Plan + +### Phase 1 — Multi-source capture (adapter + video model) +- `buildVideoFromNip71Event` (and the 30078 path where applicable) should expose a + `sources` list: every imeta `url` whose `m` is a video type (and the magnet), + in order, each with `{ url, mimeType, sha256, dim, duration }`. Keep the primary + `url`/`magnet` for back-compat; add `sources` for fallback. +- Skip non-video media (e.g. `audio/mpeg`) for the *video* player, but we may keep + audio as a separate affordance later (out of scope). + +### Phase 2 — Liveness uses real playability across sources +- The card URL probe should try sources **in order** via the video-element probe + (`confirmPlayable`); `healthy` if ANY source loads; `offline` only if ALL fail. +- Cache per-source results (already have a probe cache) so scrolling doesn't + re-probe. +- Player playback should consume the same ordered `sources` so the one that + probed healthy is what plays (and can fail over at play time too). + +### Phase 3 — Speed +- Lower `URL_PROBE_TIMEOUT_MS` (8s → ~3–4s) and the video-element probe timeout; + keep one retry only for `timeout` (not `error`). Consider bumping URL-probe + concurrency (it's cheap vs WebTorrent swarm joins). +- WebTorrent `PROBE_TIMEOUT_MS` (20s): keep generous (swarm joins are slow) but + ensure it runs in parallel and never blocks the CDN verdict. +- DECISION NEEDED: **show-pending (current) vs hide-until-verified.** Showing + pending means a brief flash of soon-to-be-hidden dead cards; hide-until-verified + means good cards pop in after the probe. Recommend: keep show-pending for cards + with a *bitvid-native/whitelisted* source (fast/trusted), but **hide-until-verified + for foreign/ingested** videos (so unplayable strangers never flash in). + +## Reuse map +- `js/ui/urlHealthController.js` (probeUrl / probeUrlWithVideoElement) — make + it iterate sources + prefer the video-element verdict. +- `js/gridHealth.js` (WebTorrent swarm probe) — timing only. +- `js/utils/cardSourceVisibility.js` — policy already correct; no change expected. +- `js/nostr/nip71IngestAdapter.js` — add `sources`. +- `js/constants.js` — `URL_PROBE_TIMEOUT_MS`, probe concurrency. +- Player/playback service — consume `sources` for fail-over. diff --git a/docs/zap-audit-plan.md b/docs/zap-audit-plan.md new file mode 100644 index 00000000..b8c51cfa --- /dev/null +++ b/docs/zap-audit-plan.md @@ -0,0 +1,62 @@ +# Zap System Audit — Plan & Findings + +Status: **in progress** (audit started 2026-06-23). Branch: `unstable`. +Pre-launch priority #3. The zap flow feels clunky and errors on send. + +## Architecture (map for the next session) +- **Two controllers**: `js/ui/components/video-modal/zapController.js` (the in-modal + UI — button, popover, comment/amount inputs; dispatches `video:zap` / `zap:open`) + and `js/ui/zapController.js` (the logic — `open()`, `sendZap()`, retry, receipts). + `ModalManager` wires `video:zap → zapController.sendZap`. +- **Payment flow**: `sendZap` → `splitAndZap` (`js/payments/zapSplit.js`) → per share + (`creator`, `platform`) `processShare` → `lnurl.fetchPayServiceData` → + `buildZapRequest` (kind 9734, comment in `content`) → `lnurl.requestInvoice` + (`nostr=` zapRequest + optional `comment=` param) → `wallet.sendPayment` (NWC, + `nwcClient.js`) → `zapReceiptValidator` (kind 9735). +- **Fee split**: `PLATFORM_FEE_PERCENT` (instance-config, currently 30) → + `platformShare = floor(amount*fee/100)`, `creatorShare = amount - platformShare`; + platform address via `platformAddress.js` / `PLATFORM_LUD16_OVERRIDE`. + +## Findings so far +- **Send error = recipient LNURL unreachable (CORS/offline).** [PARTLY FIXED] + Sending to a creator whose LNURL host is down or lacks CORS headers (observed: + `unitypay.cash/.well-known/lnurlp/goblinbox`) makes the browser `fetch` reject + with "Failed to fetch". Shipped (`dd1ce113`): `fetchPayServiceData` now throws a + clear, coded (`lnurl-unreachable`) message. **Root issue remains**: a static + client CANNOT fetch CORS-less LNURL hosts — see "CORS proxy" below. +- **Comment box works.** Confirmed via screenshot — the textarea renders and binds; + the comment is carried in the zap-request `content` + the LNURL `comment` param. + The earlier "message doesn't work" was the popover being mis-positioned/hard to + use (see 3a), not a comment bug. +- **Popover mis-positioning (zap + embed)** — see TODO 3a; shared `popoverEngine`. + +## To do (next session, in priority order) +1. **CORS / LNURL proxy decision (the real reliability fix).** A static client + can't fetch LNURL endpoints that omit CORS headers, so zaps to many creators + fail. Options: (a) a small server-side/edge proxy (Vercel function) that fetches + the LNURL pay-service data + invoice and returns it with CORS — what most web + wallets do; (b) document the limitation and only support CORS-enabled hosts; + (c) route via the connected NWC wallet if it can resolve LN addresses. Decide, + then implement. Confirm whether `unitypay.cash` is also just offline. +2. **Popover positioning (3a).** Fix the shared engine safely (portal in-modal + panels out to `#uiOverlay`, or remove the modal-subtree containing block). + Needs the live computed `position/left/top` + DOM parent of `#modalZapDialog`. + VERIFY non-modal popovers still position right after. +3. **Platform-fee split correctness.** Verify recipients/percentages/rounding; + ensure the fee can't swallow the whole zap, be bypassed, or (earlier report) + land the platform fee in the *sender's* own wallet when zapping certain targets. + Check the self-zap / creator==platform edge case. Add cheat-resistant tests. +4. **Receipt validation (kind 9735).** Confirm accuracy before crediting; the + `unvalidatedReceipts`/`failureReceipts` paths and the "Awaiting validated zap + receipt" state. (Earlier fix: `zapReceiptValidator` SimplePool `.list` shim.) +5. **NWC error UX.** "Budget exceeded" and partial-share retry flow — make the + messaging + retry obviously usable (it's currently terse). +6. **General clunkiness.** Step back on the whole flow: amount presets, the + split summary, success/partial/failure states, focus, and mobile. + +## Cheat-resistant testing notes +- `fetchPayServiceData` / `requestInvoice` accept an injectable `fetcher` — test + failure modes by injecting rejecting/erroring fetchers (see + `tests/lnurl-fetch-error.test.mjs`). +- `splitAndZap` takes injectable `dependencies` (see `tests/zap-split.test.mjs`) — + use it to assert fee math, rounding, and recipient routing without real LN. diff --git a/js/accessControl.js b/js/accessControl.js index 67cc834b..0d31e511 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -492,6 +492,12 @@ class AccessControl { return Array.from(this.whitelist); } + // Hex-encoded whitelist pubkeys (resolved from npubs). Used by NIP-71 ingest + // to scope its relay subscription to authors this instance already allows. + getWhitelistPubkeys() { + return Array.from(this.whitelistPubkeys); + } + getBlacklist() { return Array.from(this.blacklist); } diff --git a/js/adminListBatch.js b/js/adminListBatch.js new file mode 100644 index 00000000..83f7a385 --- /dev/null +++ b/js/adminListBatch.js @@ -0,0 +1,88 @@ +// Pure helpers for batching the community-blacklist curator fetch into a single +// multi-author REQ instead of one kind-30000 REQ per curator (the cold-start +// relay storm). Kept dependency-free so they're cheat-resistant to test. + +// Build a single batched filter for many (authorHex, dTag) references. A Nostr +// filter ORs `authors` and ORs `#d`, so one REQ replaces N per-curator REQs. +// Over-fetches author×dtag cross-products, which selectNewestEventsForReferences +// discards when matching back to exact references. +export function buildBatchedReferenceFilter(references, { kind = 30000 } = {}) { + const authors = new Set(); + const dTags = new Set(); + for (const ref of Array.isArray(references) ? references : []) { + const author = + typeof ref?.authorHex === "string" ? ref.authorHex.trim().toLowerCase() : ""; + const dTag = typeof ref?.dTag === "string" ? ref.dTag.trim() : ""; + if (author) authors.add(author); + if (dTag) dTags.add(dTag); + } + if (!authors.size || !dTags.size) { + return null; + } + return { + kinds: [kind], + authors: Array.from(authors), + "#d": Array.from(dTags), + }; +} + +function dTagOf(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d") { + return typeof tag[1] === "string" ? tag[1] : ""; + } + } + return ""; +} + +// Group batched events to the newest per (author, dTag), then return the events +// matching the requested references (in reference order). Cross-product events +// (author×dtag combinations that weren't requested) are discarded. +export function selectNewestEventsForReferences(events, references) { + const newest = new Map(); // key `${pubkey}:${dtag}` -> event + for (const event of Array.isArray(events) ? events : []) { + const pubkey = typeof event?.pubkey === "string" ? event.pubkey.toLowerCase() : ""; + const dTag = dTagOf(event); + if (!pubkey || !dTag) { + continue; + } + const key = `${pubkey}:${dTag}`; + const prev = newest.get(key); + if ( + !prev || + event.created_at > prev.created_at || + (event.created_at === prev.created_at && event.id > prev.id) + ) { + newest.set(key, event); + } + } + const matched = []; + for (const ref of Array.isArray(references) ? references : []) { + const author = typeof ref?.authorHex === "string" ? ref.authorHex.toLowerCase() : ""; + const dTag = typeof ref?.dTag === "string" ? ref.dTag : ""; + const event = newest.get(`${author}:${dTag}`); + if (event) { + matched.push(event); + } + } + return matched; +} + +// Fetch all curator blacklists in ONE batched REQ and return the newest event +// matched to each reference. `listEvents` is injected (the subscription +// manager's health-gated, timeout-bounded list) so this stays off the raw pool. +// Returns [] when there's nothing to batch; rejects on fetch failure. +export async function fetchBatchedReferenceEvents({ + references, + listEvents, + relays, + timeoutMs, +}) { + const filter = buildBatchedReferenceFilter(references); + if (!filter || typeof listEvents !== "function") { + return []; + } + const events = await listEvents({ filters: [filter], relays, timeoutMs }); + return selectNewestEventsForReferences(events, references); +} diff --git a/js/adminListStore.js b/js/adminListStore.js index 83eddb5e..140c1696 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -22,6 +22,7 @@ import { } from "./nostrEventSchemas.js"; import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { publishEventToRelay } from "./nostrPublish.js"; +import { fetchBatchedReferenceEvents } from "./adminListBatch.js"; const ADMIN_STATE_CACHE_VERSION = 1; const ADMIN_STATE_CACHE_KEY = `bitvid_admin_state_v${ADMIN_STATE_CACHE_VERSION}`; @@ -688,25 +689,24 @@ async function loadCommunityBlacklistEntries() { return []; } - const results = await Promise.all( - references.map(async (reference) => { - try { - const event = await fetchLatestListEvent( - { "#d": [reference.dTag], authors: [reference.authorHex] }, - reference.dTag, - ); - return event ? extractNpubsFromEvent(event) : []; - } catch (error) { - devLogger.warn( - `[adminListStore] Failed to load community blacklist ${reference.dTag}:`, - error, - ); - return []; - } - }) - ); - - return results.flat(); + // Batch all curator blacklists into ONE REQ instead of one-per-curator — this + // was the cold-start kind-30000 relay storm. Best-effort: on failure we return + // nothing and the cached admin state (readCachedAdminState) carries over. + try { + const matched = await fetchBatchedReferenceEvents({ + references, + listEvents: (args) => requireNostrClient().getSubscriptionManager().list(args), + relays: ensureNostrReady(), + timeoutMs: SHORT_TIMEOUT_MS, + }); + return matched.flatMap((event) => extractNpubsFromEvent(event)); + } catch (error) { + devLogger.warn( + "[adminListStore] Batched community blacklist fetch failed:", + error, + ); + return []; + } } function buildListEvent(listKey, npubs, actorHex) { diff --git a/js/app.js b/js/app.js index ad5ce4fc..59db1b8a 100644 --- a/js/app.js +++ b/js/app.js @@ -135,7 +135,7 @@ import resolveVideoPointer, { buildVideoAddressPointer, } from "./utils/videoPointer.js"; import { isValidMagnetUri } from "./utils/magnetValidators.js"; -import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; +import { dedupeToNewestByRoot, dedupeVideos } from "./utils/videoDeduper.js"; import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js"; import { batchFetchProfilesFromRelays } from "./utils/profileBatchFetcher.js"; import { @@ -633,6 +633,7 @@ class Application { fetchProfileMetadata, ensureProfileMetadataSubscription, dedupeToNewestByRoot, + dedupeVideos, buildServiceWorkerFallbackStatus, sanitizeProfileMediaUrl, UNSUPPORTED_BTITH_MESSAGE, diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 78dbedab..e9f00649 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -56,6 +56,7 @@ export function createPlaybackCoordinator(deps) { fetchProfileMetadata, ensureProfileMetadataSubscription, dedupeToNewestByRoot, + dedupeVideos, buildServiceWorkerFallbackStatus, sanitizeProfileMediaUrl, UNSUPPORTED_BTITH_MESSAGE, @@ -216,7 +217,11 @@ export function createPlaybackCoordinator(deps) { if (!Array.isArray(videos) || videos.length === 0) { return []; } - return dedupeToNewestByRoot(videos); + // Root-dedupe AND collapse cross-ecosystem duplicates (prefer bitvid). This + // is the shared chokepoint for the feed stage + channel/search grids. + return typeof dedupeVideos === "function" + ? dedupeVideos(videos) + : dedupeToNewestByRoot(videos); }, autoplayModalVideo() { diff --git a/js/channelProfile.js b/js/channelProfile.js index c4449fd9..e8e4935d 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -1,7 +1,8 @@ // js/channelProfile.js import { nostrClient } from "./nostrClientFacade.js"; -import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js"; +import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js"; +import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; import { attachHealthBadges } from "./gridHealth.js"; @@ -4427,10 +4428,10 @@ function getCachedChannelVideoEvents(pubkey) { } }); - if (rawMatches.length) { - return rawMatches; - } - + // Include processed videos from allEvents too (raw 30078 first so they win the + // id-dedupe downstream): this surfaces ingested NIP-71 videos for the author so + // their wall renders optimistically on revisit / when the feed already loaded + // them. Cross-ecosystem dedupe collapses any overlap. const processedMatches = collectMatches( nostrClient?.allEvents, (callback) => { @@ -4447,7 +4448,7 @@ function getCachedChannelVideoEvents(pubkey) { } ); - return processedMatches; + return [...rawMatches, ...processedMatches]; } function normalizeRenderableChannelVideo(entry) { @@ -4461,7 +4462,7 @@ function normalizeRenderableChannelVideo(entry) { typeof entry.kind === "number"; if (looksLikeRawEvent) { - const converted = sharedConvertEventToVideo(entry); + const converted = convertChannelEvent(entry); if (converted && !converted.invalid) { return converted; } @@ -4512,7 +4513,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) { const newestByRoot = app?.dedupeVideosByRoot?.(uniqueVideos) ?? - dedupeToNewestByRoot(uniqueVideos); + dedupeVideos(uniqueVideos); let videos = newestByRoot.filter((video) => !video.deleted); videos = videos.filter((video) => { @@ -5352,10 +5353,13 @@ async function loadUserVideos(pubkey) { let renderedFromCache = false; if (container) { + // In-memory cache (warm) first; else the persisted per-channel cache so a + // COLD hard-refresh paints last-seen videos before relays connect. const cachedEvents = getCachedChannelVideoEvents(pubkey); - if (cachedEvents.length) { + const cacheSource = cachedEvents.length ? cachedEvents : loadCachedChannelVideos(pubkey); + if (cacheSource.length) { const cachedVideos = buildRenderableChannelVideos({ - events: cachedEvents, + events: cacheSource, app }); if (cachedVideos.length) { @@ -5382,13 +5386,8 @@ async function loadUserVideos(pubkey) { }); try { - // 1) Build filter for videos from this pubkey - const filter = { - kinds: [30078], - authors: [pubkey], - "#t": ["video"], - limit: 200 - }; + // 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71. + const filters = buildChannelVideoFilters(pubkey); // 2) Collect raw events from all relays const events = []; @@ -5401,7 +5400,7 @@ async function loadUserVideos(pubkey) { try { const fallbackEvents = await nostrClient.pool.list( Array.from(DEFAULT_RELAY_URLS), - [filter] + filters ); if (Array.isArray(fallbackEvents)) { events.push(...fallbackEvents); @@ -5412,7 +5411,7 @@ async function loadUserVideos(pubkey) { } else { const settled = await pMapSettled( relayList, - (url) => nostrClient.pool.list([url], [filter]), + (url) => nostrClient.pool.list([url], filters), { concurrency: RELAY_BACKGROUND_CONCURRENCY } ); settled.forEach((result, index) => { @@ -5442,6 +5441,7 @@ async function loadUserVideos(pubkey) { } const videos = buildRenderableChannelVideos({ events, app }); + saveCachedChannelVideos(pubkey, videos); const rendered = await renderChannelVideosFromList({ videos, container, diff --git a/js/channelProfileVideos.js b/js/channelProfileVideos.js new file mode 100644 index 00000000..766d8f3b --- /dev/null +++ b/js/channelProfileVideos.js @@ -0,0 +1,85 @@ +// Channel-profile video sourcing: a creator's wall should show BOTH their +// bitvid-native kind-30078 videos AND the NIP-71 videos they published via other +// apps (Nostube etc.) — matching what the main feed shows via ingest. Previously +// the profile fetched only kind-30078, so cross-posted NIP-71 videos were absent. + +import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js"; +import { buildVideoFromNip71Event, NIP71_KINDS } from "./nostr/nip71IngestAdapter.js"; + +// Two filters for one author: native video notes (kind 30078, tagged "video") +// and their NIP-71 videos (kinds 21/22/34235/34236). +export function buildChannelVideoFilters(pubkey, { limit = 200 } = {}) { + return [ + { kinds: [30078], authors: [pubkey], "#t": ["video"], limit }, + { kinds: Array.from(NIP71_KINDS), authors: [pubkey], limit }, + ]; +} + +// Convert a raw channel event to a bitvid video object. Foreign NIP-71 kinds go +// through the ingest adapter (which also rejects bitvid's own outbound mirrors so +// they don't duplicate the canonical kind-30078 note); everything else uses the +// standard kind-30078 converter. May return an { invalid: true } object — the +// caller filters those out (same contract as convertEventToVideo). +export function convertChannelEvent(entry) { + const kind = Number(entry?.kind); + if (NIP71_KINDS.has(kind)) { + return buildVideoFromNip71Event(entry); + } + return sharedConvertEventToVideo(entry); +} + +// ---- Per-channel persisted cache (instant cold-load paint) ------------------- +// Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile, +// render the last-seen videos from localStorage BEFORE relays connect, then the +// live fetch replaces them. Keyed per author; bounded so storage can't grow +// without limit. + +const CHANNEL_CACHE_KEY = "bitvid:channel-videos:v1"; +const MAX_CACHED_CHANNELS = 20; +const MAX_VIDEOS_PER_CHANNEL = 60; + +function normPubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; +} + +function readChannelCacheMap() { + try { + if (typeof localStorage === "undefined") return {}; + const raw = localStorage.getItem(CHANNEL_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +export function loadCachedChannelVideos(pubkey) { + const pk = normPubkey(pubkey); + if (!pk) return []; + const entry = readChannelCacheMap()[pk]; + return entry && Array.isArray(entry.videos) ? entry.videos : []; +} + +export function saveCachedChannelVideos(pubkey, videos) { + const pk = normPubkey(pubkey); + if (!pk || !Array.isArray(videos) || typeof localStorage === "undefined") { + return; + } + try { + const map = readChannelCacheMap(); + // Drop the heavy raw `tags` array — the render + dedupe paths use parsed + // top-level fields; the live fetch restores full objects moments later. + const slim = videos.slice(0, MAX_VIDEOS_PER_CHANNEL).map(({ tags, ...rest }) => rest); + map[pk] = { videos: slim, ts: Date.now() }; + const keys = Object.keys(map); + if (keys.length > MAX_CACHED_CHANNELS) { + keys + .sort((a, b) => (map[a]?.ts || 0) - (map[b]?.ts || 0)) + .slice(0, keys.length - MAX_CACHED_CHANNELS) + .forEach((k) => delete map[k]); + } + localStorage.setItem(CHANNEL_CACHE_KEY, JSON.stringify(map)); + } catch (error) { + // best-effort; cache is non-critical + } +} diff --git a/js/config.js b/js/config.js index 4f9e6b98..d4e20286 100644 --- a/js/config.js +++ b/js/config.js @@ -24,6 +24,7 @@ import { ADMIN_WHITELIST_MODE_STORAGE_KEY, DEFAULT_WHITELIST_MODE_ENABLED, ALLOW_NSFW_CONTENT, + FEATURE_NIP71_INGEST, DEFAULT_TRUST_SEED_NPUBS, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, @@ -117,6 +118,7 @@ export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources"; export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist"; export { ALLOW_NSFW_CONTENT }; +export { FEATURE_NIP71_INGEST }; export { DEFAULT_TRUST_SEED_NPUBS }; export { DEFAULT_BLUR_THRESHOLD }; export { DEFAULT_AUTOPLAY_BLOCK_THRESHOLD }; diff --git a/js/constants.js b/js/constants.js index b664ae45..5cb050cc 100644 --- a/js/constants.js +++ b/js/constants.js @@ -6,6 +6,7 @@ import { DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, DEFAULT_PLAYBACK_SOURCE, DEFAULT_PLAYBACK_START_TIMEOUT, + FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST, IS_DEV_MODE, } from "./config.js"; @@ -35,7 +36,10 @@ export const DEFAULT_CACHE_TTL_MS = FIVE_MINUTES_MS; export const PROFILE_CACHE_TTL_MS = TEN_MINUTES_MS; export const URL_HEALTH_TTL_MS = FORTY_FIVE_MINUTES_MS; export const URL_HEALTH_RETRY_MS = FIVE_MINUTES_MS; -export const URL_PROBE_TIMEOUT_MS = 8 * ONE_SECOND_MS; +// Per-source playability probe budget. Kept tight so a dead host yields a fast +// verdict (and the card hides quickly) — a slow-but-valid CDN still gets a retry +// via URL_HEALTH/RETRY paths. Lowered from 8s. +export const URL_PROBE_TIMEOUT_MS = 4 * ONE_SECOND_MS; function coerceNonNegativeInteger(value, fallback) { const numeric = Number(value); @@ -182,6 +186,12 @@ const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player FEATURE_WATCH_HISTORY_V2: true, FEATURE_PUBLISH_NIP71: false, + // Opt-in NIP-71 *mirror* (addressable 34235/36) managed per-video from the + // My Videos tab. Independent of FEATURE_PUBLISH_NIP71 (legacy 21/22 auto-publish). + FEATURE_NIP71_MIRROR: true, + // Inbound ingest of NIP-71 videos published by other Nostr apps. Sourced from + // the admin instance config so a deployer flips it in one place. + FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false, FEATURE_HASHTAG_PREFERENCES: false, FEATURE_SEARCH_FILTERS: Boolean(IS_DEV_MODE), FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes. @@ -202,6 +212,8 @@ const runtimeFlags = (() => { URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED, FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71, + FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR, + FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST, FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES, FEATURE_SEARCH_FILTERS: DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS, FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS, @@ -233,6 +245,16 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean( DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71 ); +export let FEATURE_NIP71_MIRROR = coerceBoolean( + runtimeFlags.FEATURE_NIP71_MIRROR, + DEFAULT_FLAGS.FEATURE_NIP71_MIRROR +); + +export let FEATURE_NIP71_INGEST = coerceBoolean( + runtimeFlags.FEATURE_NIP71_INGEST, + DEFAULT_FLAGS.FEATURE_NIP71_INGEST +); + export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean( runtimeFlags.FEATURE_HASHTAG_PREFERENCES, DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 0fb18a06..44d74579 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -176,6 +176,12 @@ function normalizeNip71Kind(value) { if (numeric === 22) { return 22; } + if (numeric === 34235) { + return 34235; + } + if (numeric === 34236) { + return 34236; + } return 21; } @@ -199,6 +205,9 @@ function buildImetaTags(variants) { const dim = stringFromInput(variant.dim); const url = stringFromInput(variant.url); const x = stringFromInput(variant.x); + const ox = stringFromInput(variant.ox); + const magnet = stringFromInput(variant.magnet); + const infohash = stringFromInput(variant.i); const rawMime = stringFromInput(variant.m); const mime = rawMime ? rawMime.toLowerCase() : ""; const duration = formatNonNegativeNumber(variant.duration, { @@ -221,6 +230,9 @@ function buildImetaTags(variants) { !dim && !url && !x && + !ox && + !magnet && + !infohash && !mime && !duration && !bitrate && @@ -241,9 +253,19 @@ function buildImetaTags(variants) { if (x) { entries.push(`x ${x}`); } + if (ox) { + entries.push(`ox ${ox}`); + } if (mime) { entries.push(`m ${mime}`); } + // WebTorrent rides standard NIP-94 imeta fields so torrent-aware clients can use it. + if (magnet) { + entries.push(`magnet ${magnet}`); + } + if (infohash) { + entries.push(`i ${infohash}`); + } if (duration) { entries.push(`duration ${duration}`); } @@ -456,6 +478,15 @@ function parseImetaTag(tag) { case "x": variant.x = parsed.value; break; + case "ox": + variant.ox = parsed.value; + break; + case "magnet": + variant.magnet = parsed.value; + break; + case "i": + variant.i = parsed.value; + break; case "m": variant.m = parsed.value; break; @@ -489,6 +520,9 @@ function parseImetaTag(tag) { Boolean(variant.dim) || Boolean(variant.url) || Boolean(variant.x) || + Boolean(variant.ox) || + Boolean(variant.magnet) || + Boolean(variant.i) || Boolean(variant.m) || (variant.duration !== undefined && variant.duration !== null && variant.duration !== "") || (variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") || @@ -1481,6 +1515,20 @@ export function convertEventToVideo(event = {}) { const isPrivate = parsedContent.isPrivate === true; const isNsfw = parsedContent.isNsfw === true; const isForKids = parsedContent.isForKids === true && !isNsfw; + const hashtags = Array.isArray(parsedContent.hashtags) + ? parsedContent.hashtags + .filter((value) => typeof value === "string" && value.trim()) + .map((value) => value.trim()) + : []; + const toPositiveInt = (value) => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; + }; + const width = toPositiveInt(parsedContent.width); + const height = toPositiveInt(parsedContent.height); + const durationValue = Number(parsedContent.duration); + const duration = + Number.isFinite(durationValue) && durationValue > 0 ? durationValue : undefined; const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; const wsField = safeTrim(parsedContent.ws); const xsField = safeTrim(parsedContent.xs); @@ -1584,6 +1632,10 @@ export function convertEventToVideo(event = {}) { description, mode, deleted, + hashtags, + width, + height, + duration, ws, xs, storagePointer, diff --git a/js/nostr/nip71IngestAdapter.js b/js/nostr/nip71IngestAdapter.js new file mode 100644 index 00000000..00ae4e65 --- /dev/null +++ b/js/nostr/nip71IngestAdapter.js @@ -0,0 +1,251 @@ +// Adapter: converts a *foreign* NIP-71 video event (published by another Nostr +// app — kinds 21/22 regular, 34235/34236 addressable) into a bitvid video +// object shaped like the output of convertEventToVideo, so it can flow through +// the existing feed pipeline (moderation, trust, NSFW gating, rendering). +// +// This is the inverse of nip71Mirror.js (which builds bitvid → NIP-71). It reads +// the imeta variant + standard NIP-71 tags rather than bitvid's JSON content, +// and maps the `content-warning` tag to bitvid's `isNsfw` flag. +// +// Pure + dependency-free so it is cheat-resistant to test. The ingest *service* +// (nip71IngestService.js) handles subscriptions, dedup, and own-mirror skipping. + +import { extractNip71MetadataFromTags } from "./nip71.js"; +import { extractBtihFromMagnet, extractMagnetHints } from "../magnetShared.js"; + +const NIP71_KINDS = new Set([21, 22, 34235, 34236]); + +function trimString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseDim(dim) { + const raw = trimString(dim); + const match = /^(\d+)x(\d+)$/i.exec(raw); + if (!match) { + return { width: undefined, height: undefined }; + } + const width = Number(match[1]); + const height = Number(match[2]); + return { + width: Number.isFinite(width) && width > 0 ? width : undefined, + height: Number.isFinite(height) && height > 0 ? height : undefined, + }; +} + +function dTagFromEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "d") { + const value = trimString(tag[1]); + if (value) { + return value; + } + } + } + return ""; +} + +// bitvid stamps its own outbound mirrors with ["client","bitvid"]. The ingest +// service uses this to avoid re-listing our own content (which already exists as +// a canonical kind-30078 video). +export function isBitvidMirrorEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + return tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "client" && + trimString(tag[1]).toLowerCase() === "bitvid", + ); +} + +function pickPrimaryVariant(imeta) { + if (!Array.isArray(imeta) || !imeta.length) { + return null; + } + // Prefer a variant that advertises a video mimetype and a url; fall back to + // the first variant that has any playable source. + const withVideoUrl = imeta.find( + (variant) => + trimString(variant?.url) && + trimString(variant?.m).toLowerCase().startsWith("video/"), + ); + if (withVideoUrl) { + return withVideoUrl; + } + const withUrl = imeta.find((variant) => trimString(variant?.url)); + if (withUrl) { + return withUrl; + } + return imeta.find((variant) => trimString(variant?.magnet)) || null; +} + +function isVideoVariant(variant) { + if (!trimString(variant?.url)) { + return false; + } + const m = trimString(variant?.m).toLowerCase(); + // Accept explicit video/* and untyped variants; exclude audio/image/etc. + return m === "" || m.startsWith("video/"); +} + +// All playable video URLs from a NIP-71 event's imeta variants, in order, so the +// player + liveness probe can fail over if one host is down. Audio/image +// variants are excluded. De-duplicated by url. +export function collectVideoSources(imeta) { + const out = []; + const seen = new Set(); + for (const variant of Array.isArray(imeta) ? imeta : []) { + if (!isVideoVariant(variant)) { + continue; + } + const url = trimString(variant.url); + if (!url || seen.has(url)) { + continue; + } + seen.add(url); + const durationNum = Number(variant.duration); + out.push({ + url, + mimeType: trimString(variant.m), + sha256: trimString(variant.x), + dim: trimString(variant.dim), + duration: + Number.isFinite(durationNum) && durationNum > 0 ? durationNum : undefined, + }); + } + return out; +} + +/** + * Convert a foreign NIP-71 event into a bitvid video object. + * + * @param {object} event - The raw Nostr event (kind 21/22/34235/34236). + * @returns {object} A video object (invalid:false) or { invalid:true, reason }. + */ +export function buildVideoFromNip71Event(event = {}) { + if (!event || typeof event !== "object") { + return { invalid: true, reason: "missing event" }; + } + if (!NIP71_KINDS.has(Number(event.kind))) { + return { id: event.id, invalid: true, reason: "not a NIP-71 video kind" }; + } + if (isBitvidMirrorEvent(event)) { + // Our own outbound mirror — the canonical 30078 is already in the feed. + return { id: event.id, invalid: true, reason: "bitvid-mirror" }; + } + + const parsed = extractNip71MetadataFromTags(event); + const metadata = parsed?.metadata || {}; + const variant = pickPrimaryVariant(metadata.imeta); + + const url = trimString(variant?.url); + const rawMagnet = trimString(variant?.magnet); + const magnet = rawMagnet.toLowerCase().startsWith("magnet:?") ? rawMagnet : ""; + + if (!url && !magnet) { + return { id: event.id, invalid: true, reason: "missing playable source" }; + } + + const title = trimString(metadata.title); + if (!title) { + return { id: event.id, invalid: true, reason: "missing title" }; + } + + const { width, height } = parseDim(variant?.dim); + + const durationCandidate = Number(metadata.duration); + const duration = + Number.isFinite(durationCandidate) && durationCandidate > 0 + ? durationCandidate + : undefined; + + const thumbnail = + Array.isArray(variant?.image) && variant.image.length + ? trimString(variant.image[0]) + : ""; + + // content-warning → isNsfw so the standard NSFW gate (ALLOW_NSFW_CONTENT) and + // blur thresholds apply to ingested content too. + const isNsfw = Boolean(trimString(metadata.contentWarning)); + + const hashtags = Array.isArray(metadata.hashtags) + ? metadata.hashtags + .map((value) => trimString(value)) + .filter((value) => value) + : []; + + let infoHash = ""; + const iField = trimString(variant?.i).toLowerCase(); + if (/^[0-9a-f]{40}$/.test(iField)) { + infoHash = iField; + } else if (magnet) { + infoHash = extractBtihFromMagnet(magnet) || ""; + } + + const hints = magnet ? extractMagnetHints(magnet) : { ws: "", xs: "" }; + + // Addressable kinds (34235/36) are keyed by d-tag; regular kinds (21/22) use + // the event id as the stable root. + const dTag = dTagFromEvent(event); + const videoRootId = dTag || trimString(event.id); + + // All playable video URLs (mirrors) for fail-over at probe + play time. + const sources = collectVideoSources(metadata.imeta); + + // The feed's resolve-posted-at stage fetches per-video kind-30078 history when + // a timestamp isn't already known. Foreign NIP-71 videos have no such history, + // so without a posted-at the feed would fire a blocking history fetch per + // ingested video (a relay storm that stalls the render). Surface the NIP-71 + // published_at (falling back to created_at) so the feed short-circuits. + const createdAt = Number.isFinite(event.created_at) ? Math.floor(event.created_at) : 0; + const publishedAtNum = Number(metadata.publishedAt); + const publishedAt = + Number.isFinite(publishedAtNum) && publishedAtNum > 0 + ? Math.floor(publishedAtNum) + : createdAt; + + return { + id: event.id, + videoRootId, + version: 3, + isPrivate: false, + isNsfw, + isForKids: false, + title, + url, + sources, + magnet, + rawMagnet: magnet ? rawMagnet : "", + infoHash, + fileSha256: trimString(variant?.x), + originalFileSha256: trimString(variant?.ox), + thumbnail, + description: trimString(metadata.summary), + mode: "live", + deleted: false, + hashtags, + width, + height, + duration, + ws: hints.ws || "", + xs: hints.xs || "", + storagePointer: null, + infoJsonUrl: "", + enableComments: true, + pubkey: trimString(event.pubkey), + created_at: createdAt, + // Lets the feed's resolve-posted-at stage short-circuit instead of fetching + // non-existent kind-30078 history for foreign videos. + nip71: { publishedAt }, + tags: Array.isArray(event.tags) ? event.tags : [], + // Provenance markers so the rest of the app can distinguish ingested videos + // from native bitvid uploads (e.g. to hide WebTorrent-only affordances). + source: "nip71-ingest", + foreign: true, + nip71Kind: Number(event.kind), + invalid: false, + }; +} + +export { NIP71_KINDS }; diff --git a/js/nostr/nip71Mirror.js b/js/nostr/nip71Mirror.js new file mode 100644 index 00000000..7a7faf74 --- /dev/null +++ b/js/nostr/nip71Mirror.js @@ -0,0 +1,196 @@ +// Phase 0 of the NIP-71 interop plan (docs/nip71-migration-plan.md). +// +// Maps a bitvid video (the kind-30078 content shape) to an opt-in, addressable +// NIP-71 *mirror* event (kind 34235 normal / 34236 short). The 30078 stays the +// canonical record; this mirror is additive and lets the video show up on other +// Nostr video clients (Nostube, Amethyst, …). +// +// Pure / no UX wiring (gated by FEATURE_PUBLISH_NIP71 at the call sites later). +// Hard rules baked in: private videos are NEVER mirrored, and a publicly-playable +// HTTPS url is required (foreign clients can't play magnet-only). bitvid's +// WebTorrent rides standard NIP-94 imeta fields (magnet, i). + +import { buildNip71MetadataTags, buildVideoPointerValue } from "./nip71.js"; +import { extractBtihFromMagnet } from "../magnetShared.js"; + +export const NIP71_NORMAL_VIDEO_KIND = 34235; +export const NIP71_SHORT_VIDEO_KIND = 34236; + +const VIDEO_MIME_BY_EXT = { + mp4: "video/mp4", + m4v: "video/mp4", + webm: "video/webm", + ogv: "video/ogg", + ogg: "video/ogg", + mov: "video/quicktime", + mkv: "video/x-matroska", + m3u8: "application/x-mpegURL", +}; + +function str(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function isHttpsUrl(value) { + return /^https:\/\//i.test(str(value)); +} + +// Best-effort MIME from the url extension; bitvid doesn't persist a MIME today. +export function deriveVideoMime(url) { + const value = str(url).toLowerCase(); + if (!value) { + return "video/mp4"; + } + const path = value.split(/[?#]/)[0]; + const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : ""; + return VIDEO_MIME_BY_EXT[ext] || "video/mp4"; +} + +// Hashtags can live in several places depending on how the video was authored: +// bitvid's own `hashtags`, the parsed NIP-71 metadata (`nip71.hashtags`), or the +// raw `t` tags on the 30078 (produced by the upload form's NIP-71 hashtag editor). +// Source from whichever is present so a shared video stays categorized elsewhere. +// Drops bitvid's internal "video" topic tag and dedupes (case-insensitive). +function resolveHashtags(video) { + let raw = []; + if (Array.isArray(video.hashtags) && video.hashtags.length) { + raw = video.hashtags; + } else if (Array.isArray(video.nip71?.hashtags) && video.nip71.hashtags.length) { + raw = video.nip71.hashtags; + } else if (Array.isArray(video.tags)) { + raw = video.tags + .filter((t) => Array.isArray(t) && t[0] === "t" && typeof t[1] === "string") + .map((t) => t[1]); + } + const out = []; + const seen = new Set(); + for (const value of raw) { + const tag = str(value).toLowerCase(); + if (!tag || tag === "video" || seen.has(tag)) { + continue; + } + seen.add(tag); + out.push(tag); + } + return out; +} + +function normalizePublishedAt(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return Math.floor(Date.now() / 1000); + } + // Tolerate millisecond timestamps. + return numeric > 10_000_000_000 ? Math.floor(numeric / 1000) : Math.floor(numeric); +} + +/** + * Build an addressable NIP-71 mirror event from a bitvid video object. + * + * @param {object} video - bitvid video (30078 content shape) + `pubkey`. + * @param {object} [options] + * @param {boolean} [options.short] - Force 34236 (short) / 34235 (normal). When + * omitted, inferred from dimensions (portrait => short). + * @param {number} [options.createdAt] - Signing time (unix seconds). Default now. + * @param {number|string} [options.publishedAt] - First-publish time for the + * `published_at` tag; kept stable across edits. Defaults to the video's + * published_at/created_at, else now. + * @param {string} [options.watchUrl] - Canonical bitvid watch URL for `origin`. + * @returns {{ok:true, event:object} | {ok:false, reason:string}} + */ +export function buildNip71MirrorEvent(video, options = {}) { + if (!video || typeof video !== "object") { + return { ok: false, reason: "invalid" }; + } + + const videoRootId = str(video.videoRootId); + const title = str(video.title); + const pubkey = str(video.pubkey).toLowerCase(); + if (!videoRootId || !title || !pubkey) { + return { ok: false, reason: "invalid" }; + } + + // Hard rule 1: private videos are never mirrored (no NIP-71 encryption). + if (video.isPrivate === true) { + return { ok: false, reason: "private" }; + } + + // Hard rule 2: foreign clients need a hosted, publicly-playable HTTPS url. + const url = str(video.url); + if (!isHttpsUrl(url)) { + return { ok: false, reason: "no-url" }; + } + + const magnet = str(video.magnet); + const infohash = + str(video.infoHash) || (magnet ? str(extractBtihFromMagnet(magnet)) : ""); + + const width = Number(video.width); + const height = Number(video.height); + const hasDims = + Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0; + const duration = Number(video.duration); + + const variant = { + url, + m: deriveVideoMime(url), + image: str(video.thumbnail) ? [str(video.thumbnail)] : [], + x: str(video.fileSha256) || undefined, + ox: str(video.originalFileSha256) || undefined, + magnet: magnet || undefined, + i: infohash || undefined, + dim: hasDims ? `${width}x${height}` : undefined, + duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, + }; + + const hashtags = resolveHashtags(video); + + const metadata = { + title, + // Short accessibility text — NOT a dump of the full description. + alt: title, + publishedAt: normalizePublishedAt( + options.publishedAt ?? video.published_at ?? video.created_at, + ), + imeta: [variant], + hashtags, + contentWarning: + video.isNsfw === true ? str(video.nsfwReason) || "nsfw" : undefined, + }; + + const tags = buildNip71MetadataTags(metadata); + + // Addressable identity + bitvid linkage (ignored by foreign clients). + tags.push(["d", videoRootId]); + const aValue = buildVideoPointerValue(pubkey, videoRootId); + if (aValue) { + tags.push(["a", aValue]); // back-pointer to the canonical kind 30078 + } + const origin = ["origin", "bitvid", videoRootId]; + const watchUrl = str(options.watchUrl); + if (watchUrl) { + origin.push(watchUrl); + } + tags.push(origin); + tags.push(["client", "bitvid"]); + + const short = + options.short === true || + (options.short !== false && hasDims && height > width); + + const createdAt = Number.isFinite(options.createdAt) + ? Math.floor(options.createdAt) + : Math.floor(Date.now() / 1000); + + const event = { + kind: short ? NIP71_SHORT_VIDEO_KIND : NIP71_NORMAL_VIDEO_KIND, + pubkey, + created_at: createdAt, + tags, + content: str(video.description), + }; + + return { ok: true, event }; +} + +export default buildNip71MirrorEvent; diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index 96361159..94f46e5a 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -147,17 +147,74 @@ function normalizeRelayList(relays) { .filter((relay) => relay); } +// Filter fields that relays hex-decode (from_hex) and therefore MUST contain +// 64-char lowercase hex. A single malformed/odd-length entry makes strict relays +// (e.g. primal) reject the whole REQ with "uneven size input to from_hex", +// silently dropping that subscription's results. +const HEX64_FILTER_FIELDS = ["ids", "authors", "#e", "#p", "#q"]; +const HEX64_PATTERN = /^[0-9a-f]{64}$/; + +// Strip invalid hex from the hex-only filter fields so one bad value can't get a +// whole REQ rejected. Returns the sanitized filter, or null if a hex field that +// had entries ends up empty (the query would be meaningless/over-broad without +// them, so the filter is dropped rather than silently widened). +export function sanitizeHexFilterFields(filter) { + if (!filter || typeof filter !== "object") { + return filter; + } + let sanitized = filter; + for (const field of HEX64_FILTER_FIELDS) { + const values = filter[field]; + if (!Array.isArray(values) || values.length === 0) { + continue; + } + const kept = []; + for (const value of values) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (HEX64_PATTERN.test(normalized)) { + kept.push(normalized); + } + } + const changed = + kept.length !== values.length || kept.some((value, i) => value !== values[i]); + if (!changed) { + continue; + } + const droppedCount = values.length - kept.length; + if (droppedCount > 0) { + devLogger.warn( + `[toolkit] dropped ${droppedCount} invalid hex value(s) from filter "${field}"` + + ` (kinds: ${Array.isArray(filter.kinds) ? filter.kinds.join(",") : "?"})`, + ); + } + if (kept.length === 0) { + return null; // a once-populated hex field is now empty — drop the filter + } + if (sanitized === filter) { + sanitized = { ...filter }; + } + sanitized[field] = kept; + } + return sanitized; +} + function normalizeFilterList(filters) { if (!filters) { return []; } + let list; if (!Array.isArray(filters)) { if (typeof filters === "object" && filters !== null) { - return [filters]; + list = [filters]; + } else { + return []; } - return []; + } else { + list = filters.filter((candidate) => candidate && typeof candidate === "object"); } - return filters.filter((candidate) => candidate && typeof candidate === "object"); + return list + .map((filter) => sanitizeHexFilterFields(filter)) + .filter((filter) => filter && typeof filter === "object"); } function safeInvoke(handler, args, contextLabel) { diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js index 82361bca..9410feed 100644 --- a/js/nostr/videoPayloadBuilder.js +++ b/js/nostr/videoPayloadBuilder.js @@ -5,8 +5,45 @@ import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js import { buildVideoPostEvent } from "../nostrEventSchemas.js"; import { computeSha256HexFromValue } from "../utils/cryptoUtils.js"; import { inferMimeTypeFromUrl } from "../utils/mime.js"; +import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { devLogger } from "../utils/logger.js"; +// Pixel dimensions: positive integer or 0 (= "unknown / omit"). +export function normalizeVideoDimension(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +// Duration in seconds: positive (float ok) or 0 (= omit). +export function normalizeVideoDuration(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +// Normalize free-form hashtag input (array or comma/space string) to a deduped, +// lowercased list. Drops the reserved "video" topic tag and caps the count. +export function sanitizeVideoHashtags(input) { + const raw = Array.isArray(input) + ? input + : typeof input === "string" + ? input.split(/[\s,]+/) + : []; + const out = []; + const seen = new Set(); + for (const item of raw) { + const tag = normalizeHashtag(item); + if (!tag || tag === "video" || seen.has(tag)) { + continue; + } + seen.add(tag); + out.push(tag); + if (out.length >= 20) { + break; + } + } + return out; +} + export function extractVideoPublishPayload(rawPayload) { let videoData = rawPayload; let nip71Metadata = null; @@ -161,13 +198,39 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta contentObject.xs = finalXs; } + const hashtags = sanitizeVideoHashtags(videoData.hashtags); + if (hashtags.length) { + contentObject.hashtags = hashtags; + } + + // Captured-at-upload dimensions/duration: persist so a video's orientation is + // known durably (drives 34236 short selection + a future "shorts" feed filter). + const width = normalizeVideoDimension(videoData.width); + const height = normalizeVideoDimension(videoData.height); + const duration = normalizeVideoDuration(videoData.duration); + if (width) { + contentObject.width = width; + } + if (height) { + contentObject.height = height; + } + if (duration) { + contentObject.duration = duration; + } + const nip71Tags = buildNip71MetadataTags( nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null ); - const additionalTags = storagePointer - ? [["s", storagePointer], ...nip71Tags] - : nip71Tags; + // Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and + // relay queries) pick them up, in addition to persisting them in content. + const hashtagTags = hashtags.map((tag) => ["t", tag]); + + const additionalTags = [ + ...(storagePointer ? [["s", storagePointer]] : []), + ...hashtagTags, + ...nip71Tags, + ]; const event = buildVideoPostEvent({ pubkey: normalizedPubkey, @@ -438,6 +501,30 @@ export function prepareVideoEditPayload(params) { contentObject.xs = finalXs; } + // Prefer edited hashtags, else preserve the base event's so an edit that omits + // the field never strips them. + const hashtags = sanitizeVideoHashtags( + updatedData?.hashtags !== undefined ? updatedData.hashtags : baseEvent.hashtags, + ); + if (hashtags.length) { + contentObject.hashtags = hashtags; + } + const hashtagTags = hashtags.map((tag) => ["t", tag]); + + // Preserve captured dimensions/duration across edits (prefer edited values). + const width = normalizeVideoDimension(updatedData?.width ?? baseEvent.width); + const height = normalizeVideoDimension(updatedData?.height ?? baseEvent.height); + const duration = normalizeVideoDuration(updatedData?.duration ?? baseEvent.duration); + if (width) { + contentObject.width = width; + } + if (height) { + contentObject.height = height; + } + if (duration) { + contentObject.duration = duration; + } + let metadataForTags = nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null; if (!metadataForTags) { @@ -453,9 +540,11 @@ export function prepareVideoEditPayload(params) { const nip71Tags = buildNip71MetadataTags(metadataForTags); - const additionalTags = storagePointer - ? [["s", storagePointer], ...nip71Tags] - : nip71Tags; + const additionalTags = [ + ...(storagePointer ? [["s", storagePointer]] : []), + ...hashtagTags, + ...nip71Tags, + ]; // A replaceable event only wins if its created_at is strictly greater than // the version it replaces (NIP-01 keeps the highest created_at; ties go to diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 36cb8fb5..1329be1f 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -232,10 +232,23 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { const targetUrl = sanitizeUrl(url); const fetchFn = ensureFetchFunction(fetcher); - const response = await fetchFn(targetUrl, { - method: "GET", - headers: { Accept: "application/json" }, - }); + let response; + try { + response = await fetchFn(targetUrl, { + method: "GET", + headers: { Accept: "application/json" }, + }); + } catch (error) { + // Browser fetch rejects (TypeError "Failed to fetch") when the recipient's + // Lightning endpoint is unreachable or — common for LNURL — doesn't send CORS + // headers. Surface a clear, actionable message instead of the raw error. + const friendly = new Error( + "Couldn't reach this recipient's Lightning address. It may be offline or blocking browser requests.", + ); + friendly.cause = error; + friendly.code = "lnurl-unreachable"; + throw friendly; + } if (!response.ok) { throw new Error(`Failed to load LNURL metadata (${response.status}).`); diff --git a/js/services/nip71IngestService.js b/js/services/nip71IngestService.js new file mode 100644 index 00000000..16cd4958 --- /dev/null +++ b/js/services/nip71IngestService.js @@ -0,0 +1,408 @@ +// Inbound NIP-71 ingest: subscribes to video events published by *other* Nostr +// apps (kinds 21/22/34235/34236), converts them to bitvid video objects, and +// injects them into the active-video store so they flow through the existing +// feed pipeline (whitelist, blacklist, NSFW gate, trust/mute/blur, rendering). +// +// Design — ingest mirrors the whitelist model: bitvid's whitelist is "authors +// allowed to publish/render here," so ingest pulls those same authors' NIP-71 +// content from the wider ecosystem. When whitelist mode is ON, the relay +// subscription is scoped to whitelisted pubkeys (bounded, no firehose); when a +// deployer disables whitelist mode, ingest opens to all authors (capped). +// +// All gating is reused, not re-implemented: injected videos carry +// source:"nip71-ingest" + isNsfw (mapped from content-warning), and the +// render-time filter (nostrService.shouldIncludeVideo -> accessControl.canAccess) +// remains the authority. Gated by FEATURE_NIP71_INGEST so a single admin switch +// disables it entirely. + +import { FEATURE_NIP71_INGEST } from "../constants.js"; +import { + buildVideoFromNip71Event, + NIP71_KINDS, +} from "../nostr/nip71IngestAdapter.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +const FLUSH_DEBOUNCE_MS = 200; +// Coalesce ingest-driven feed refreshes so a burst of incoming events can't +// trigger a re-render/re-subscription storm. The injected videos are already in +// the active store; this only controls how often we *signal* the feed. +const REFRESH_THROTTLE_MS = 2500; +const DEFAULT_LIMIT = 200; +const SUBSCRIPTION_KEY = "nip71-ingest"; +// The admin whitelist hydrates asynchronously (remote fetch, then a second hex +// rebuild once NostrTools loads) and does not always emit a change we can hook +// (e.g. loaded-from-cache unchanged). So if the author scope isn't ready at +// start, poll a bounded number of times until it is, instead of silently never +// subscribing. +const OPEN_RETRY_DELAY_MS = 2000; +const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries +// Defer the first subscription until the native feed has rendered (signalled by +// the first videos:updated). Injecting foreign authors hydrates their WoT graph +// (kind 30000), which during cold-start competes with the initial feed load; a +// fallback covers the empty-feed case where no videos:updated ever fires. +const FEED_READY_FALLBACK_MS = 12000; + +export function createNip71IngestService({ + nostrClient, + nostrService, + accessControl, + featureEnabled = FEATURE_NIP71_INGEST, + logger = devLogger, + flushDelayMs = FLUSH_DEBOUNCE_MS, + limit = DEFAULT_LIMIT, + openRetryDelayMs = OPEN_RETRY_DELAY_MS, + maxOpenAttempts = MAX_OPEN_ATTEMPTS, + refreshThrottleMs = REFRESH_THROTTLE_MS, + feedReadyFallbackMs = FEED_READY_FALLBACK_MS, +} = {}) { + let subscription = null; + let buffer = []; + let flushTimer = null; + let retryTimer = null; + let attemptsLeft = maxOpenAttempts; + let offWhitelistChange = null; + let started = false; + let lastRefreshAt = 0; + let pendingRefreshTimer = null; + let deferArmed = false; + let deferTimer = null; + let offFeedReady = null; + + function isAvailable() { + return ( + featureEnabled === true && + !!nostrClient && + typeof nostrClient.getSubscriptionManager === "function" + ); + } + + // Build the relay filter set. Returns null when there is nothing to ingest + // (whitelist mode on but no whitelisted authors yet). + function resolveFilters() { + const kinds = Array.from(NIP71_KINDS); + const whitelistOn = accessControl?.whitelistMode?.() === true; + if (whitelistOn) { + const authors = + typeof accessControl?.getWhitelistPubkeys === "function" + ? accessControl.getWhitelistPubkeys().filter(Boolean) + : []; + if (!authors.length) { + return null; + } + return [{ kinds, authors, limit }]; + } + return [{ kinds, limit }]; + } + + function scheduleFlush() { + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flush(); + }, flushDelayMs); + } + + // Inject a converted foreign video into the active store. Native bitvid videos + // always win for a given root; among ingested videos, newest wins. + function injectVideo(video) { + if (!video || video.invalid) { + return false; + } + const activeMap = nostrClient?.activeMap; + const allEvents = nostrClient?.allEvents; + if (!(activeMap instanceof Map) || !(allEvents instanceof Map)) { + return false; + } + const key = + typeof nostrClient.getActiveKey === "function" + ? nostrClient.getActiveKey(video) + : ""; + if (!key) { + return false; + } + const existing = activeMap.get(key); + if (existing) { + const existingForeign = existing.source === "nip71-ingest"; + if (!existingForeign) { + return false; // native bitvid video wins + } + if (Number(existing.created_at) >= Number(video.created_at)) { + return false; // older or equal ingest + } + } + activeMap.set(key, video); + allEvents.set(video.id, video); + return true; + } + + function flush() { + if (!buffer.length) { + return 0; + } + const events = buffer; + buffer = []; + + // Collapse to newest-per-root within the batch before injecting. + const newestByRoot = new Map(); + for (const event of events) { + const video = buildVideoFromNip71Event(event); + if (video.invalid || !video.videoRootId) { + continue; + } + const prev = newestByRoot.get(video.videoRootId); + if (!prev || Number(video.created_at) > Number(prev.created_at)) { + newestByRoot.set(video.videoRootId, video); + } + } + + const injectedVideos = []; + for (const video of newestByRoot.values()) { + if (injectVideo(video)) { + injectedVideos.push(video); + } + } + const injected = injectedVideos.length; + + // Also cache into nostrService.videosMap so ingested videos persist and show + // up in the author-scoped feeds + the optimistic/persisted render paths — not + // just the live activeMap engine run. Keeps NIP-71 present across navigation. + if (injected > 0 && typeof nostrService?.cacheVideos === "function") { + try { + nostrService.cacheVideos(injectedVideos); + } catch (error) { + logger?.warn?.("[nip71Ingest] cacheVideos failed", error); + } + } + + // Visible (not dev-gated) so ingest is observable in any build while we + // stabilize the feature. + userLogger.info( + `[nip71Ingest] flush: ${events.length} event(s) in, ${injected} injected`, + ); + + if (injected > 0) { + requestRefresh(); + } + + return injected; + } + + // Signal the feed to re-render, throttled so a burst of ingested events can't + // drive a re-render/re-subscription storm. The injected videos already live in + // the active store; the feed re-reads getFilteredActiveVideos itself, so this + // is purely a "refresh now" nudge. + function requestRefresh() { + if (pendingRefreshTimer) { + return; + } + const now = Date.now(); + const elapsed = now - lastRefreshAt; + if (elapsed >= refreshThrottleMs) { + emitRefresh(); + return; + } + pendingRefreshTimer = setTimeout(() => { + pendingRefreshTimer = null; + emitRefresh(); + }, refreshThrottleMs - elapsed); + } + + function emitRefresh() { + lastRefreshAt = Date.now(); + let videos = []; + try { + videos = + typeof nostrService?.getFilteredActiveVideos === "function" + ? nostrService.getFilteredActiveVideos() + : []; + } catch (error) { + logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error); + } + // Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.). + const foreignCount = Array.isArray(videos) + ? videos.filter((v) => v?.source === "nip71-ingest").length + : 0; + userLogger.info( + `[nip71Ingest] refresh: ${foreignCount} ingested of ${videos.length} in filtered feed`, + ); + if (typeof nostrService?.emit === "function") { + nostrService.emit("videos:updated", { + videos, + deleted: [], + reason: "nip71-ingest", + }); + } + } + + // Defer the first start until the native feed has rendered (first + // videos:updated), with a fallback timer for the empty-feed case. Keeps ingest + // off the cold-start critical path. + function startWhenFeedReady() { + if (started || deferArmed || !isAvailable()) { + return false; + } + deferArmed = true; + + const begin = () => { + if (typeof offFeedReady === "function") { + offFeedReady(); + offFeedReady = null; + } + if (deferTimer) { + clearTimeout(deferTimer); + deferTimer = null; + } + deferArmed = false; + start(); + }; + + if (typeof nostrService?.on === "function") { + offFeedReady = nostrService.on("videos:updated", begin); + } + deferTimer = setTimeout(begin, feedReadyFallbackMs); + return true; + } + + function start() { + if (started || !isAvailable()) { + return false; + } + started = true; + attemptsLeft = maxOpenAttempts; + + // Re-subscribe when the whitelist changes so the author scope stays current. + if (typeof accessControl?.onWhitelistChange === "function") { + offWhitelistChange = accessControl.onWhitelistChange(() => { + restart(); + }); + } + + return attemptOpen(); + } + + // Open the subscription; if the author scope isn't ready yet (whitelist still + // hydrating), retry on a timer rather than giving up silently. + function attemptOpen() { + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + if (subscription) { + return true; + } + const opened = openSubscription(); + if (opened) { + return true; + } + if (started && attemptsLeft > 0) { + attemptsLeft -= 1; + retryTimer = setTimeout(attemptOpen, openRetryDelayMs); + } + return false; + } + + function openSubscription() { + const filters = resolveFilters(); + if (!filters) { + return false; // nothing to ingest yet (e.g. whitelist not hydrated) — retry + } + try { + const manager = nostrClient.getSubscriptionManager(); + subscription = manager.subscribe({ + key: SUBSCRIPTION_KEY, + filters, + label: "nip71-ingest", + onEvent: (event) => { + if (event && NIP71_KINDS.has(Number(event.kind))) { + buffer.push(event); + scheduleFlush(); + } + }, + }); + const authorCount = Array.isArray(filters[0]?.authors) + ? filters[0].authors.length + : "all"; + userLogger.info( + `[nip71Ingest] subscribed (authors: ${authorCount}, kinds: ${filters[0]?.kinds?.join(",")})`, + ); + return true; + } catch (error) { + logger?.warn?.("[nip71Ingest] subscribe failed", error); + subscription = null; + return false; + } + } + + function closeSubscription() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + if (pendingRefreshTimer) { + clearTimeout(pendingRefreshTimer); + pendingRefreshTimer = null; + } + buffer = []; + if (subscription && typeof subscription.close === "function") { + try { + subscription.close(); + } catch (error) { + logger?.warn?.("[nip71Ingest] close failed", error); + } + } + subscription = null; + } + + function restart() { + if (!started) { + return false; + } + closeSubscription(); + attemptsLeft = maxOpenAttempts; + return attemptOpen(); + } + + function stop() { + closeSubscription(); + if (typeof offFeedReady === "function") { + try { + offFeedReady(); + } catch (error) { + // best-effort + } + offFeedReady = null; + } + if (deferTimer) { + clearTimeout(deferTimer); + deferTimer = null; + } + deferArmed = false; + if (typeof offWhitelistChange === "function") { + try { + offWhitelistChange(); + } catch (error) { + // best-effort + } + offWhitelistChange = null; + } + started = false; + } + + return { + isAvailable, + startWhenFeedReady, + start, + stop, + restart, + // Exposed for tests: deterministic flush + single-video injection. + flush, + injectVideo, + resolveFilters, + }; +} diff --git a/js/services/nip71MirrorFlags.js b/js/services/nip71MirrorFlags.js new file mode 100644 index 00000000..899e2c84 --- /dev/null +++ b/js/services/nip71MirrorFlags.js @@ -0,0 +1,148 @@ +// Per-video opt-in state for the NIP-71 mirror, persisted locally per +// pubkey+videoRootId. Off by default — a publisher explicitly opts a video in +// (from the My Videos tab). Local-only is fine: it's a convenience for the +// management UI; the authoritative state is whether the mirror event exists on +// relays. + +const FLAG_KEY = "bitvid:nip71-mirror:v1"; +const AUTO_SHARE_KEY = "bitvid:nip71-autoshare:v1"; + +function readFlags() { + try { + if (typeof localStorage === "undefined") { + return {}; + } + const raw = localStorage.getItem(FLAG_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +function writeFlags(flags) { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(FLAG_KEY, JSON.stringify(flags || {})); + } + } catch (error) { + // Best-effort. + } +} + +function norm(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function isMirrorEnabled(pubkey, videoRootId) { + const pk = norm(pubkey); + const root = norm(videoRootId); + if (!pk || !root) { + return false; + } + return readFlags()[pk]?.[root] === true; +} + +export function setMirrorEnabled(pubkey, videoRootId, enabled) { + const pk = norm(pubkey); + const root = norm(videoRootId); + if (!pk || !root) { + return; + } + const flags = readFlags(); + const entry = flags[pk] && typeof flags[pk] === "object" ? flags[pk] : {}; + if (enabled === true) { + entry[root] = true; + } else { + delete entry[root]; + } + flags[pk] = entry; + writeFlags(flags); +} + +// Pure decision for a toggle click: given the current opt-in state and the +// service's eligibility check (canMirror), what should happen? +// { action: "remove" } — currently on → tear down +// { action: "publish" } — eligible + off → publish +// { action: "blocked", reason: "<why>" } — off but ineligible (private / nsfw / no-url) +export function resolveMirrorToggle({ enabled, eligibility } = {}) { + if (enabled === true) { + return { action: "remove" }; + } + if (!eligibility || eligibility.ok !== true) { + return { action: "blocked", reason: eligibility?.reason || "ineligible" }; + } + return { action: "publish" }; +} + +// Account-level "auto-share new public videos" preference (per pubkey, off by +// default). When on, newly published eligible public videos are mirrored +// automatically — no per-video opt-in needed. +function readAutoShare() { + try { + if (typeof localStorage === "undefined") { + return {}; + } + const raw = localStorage.getItem(AUTO_SHARE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +export function isAutoShareEnabled(pubkey) { + const pk = norm(pubkey); + return pk ? readAutoShare()[pk] === true : false; +} + +export function setAutoShareEnabled(pubkey, enabled) { + const pk = norm(pubkey); + if (!pk) { + return; + } + const map = readAutoShare(); + if (enabled === true) { + map[pk] = true; + } else { + delete map[pk]; + } + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(AUTO_SHARE_KEY, JSON.stringify(map)); + } + } catch (error) { + // best-effort + } +} + +// On publish of a NEW video: mirror it only when auto-share is on AND it's +// eligible (public + hosted URL + allowed). Otherwise do nothing. +export function resolvePublishSync({ featureOn, autoShare, eligible } = {}) { + if (featureOn !== true || autoShare !== true) { + return { action: "none" }; + } + return eligible === true ? { action: "publish" } : { action: "none" }; +} + +// Lifecycle decisions for keeping an opted-in mirror in sync (used by the +// nostrService edit/delete hooks). Pure so they're cheat-resistant to test. +// +// On EDIT of a mirrored video: re-publish if still eligible; if it became +// ineligible (e.g. flipped private), pull the mirror down and clear the flag. +export function resolveEditSync({ featureOn, enabled, eligible } = {}) { + if (featureOn !== true || enabled !== true) { + return { action: "none" }; + } + return eligible === true ? { action: "publish" } : { action: "unshare" }; +} + +// On DELETE of a mirrored video: remove the mirror and clear the flag. +export function resolveDeleteSync({ featureOn, enabled } = {}) { + if (featureOn !== true || enabled !== true) { + return { action: "none" }; + } + return { action: "unshare" }; +} + +export const NIP71_MIRROR_FLAG_KEY = FLAG_KEY; diff --git a/js/services/nip71MirrorService.js b/js/services/nip71MirrorService.js new file mode 100644 index 00000000..25bbec5c --- /dev/null +++ b/js/services/nip71MirrorService.js @@ -0,0 +1,194 @@ +// Phase 1 of the NIP-71 interop plan (docs/nip71-migration-plan.md): publish the +// opt-in addressable NIP-71 mirror (kind 34235/34236) for a bitvid video to the +// user's write/outbox relays. +// +// Builds on the Phase 0 pure builder (buildNip71MirrorEvent) which already enforces +// the "private never mirrored" and "HTTPS url required" rules. This service adds: +// - the site-wide NSFW gate: when ALLOW_NSFW_CONTENT is false, an instance that +// won't surface NSFW must not publish it outward either (moderation audit item). +// - signing with the active signer + publishing to the write (outbox) relay set. +// +// Dependency-injected so it unit-tests without a live signer/relay pool. + +import { buildNip71MirrorEvent } from "../nostr/nip71Mirror.js"; +import { nostrClient } from "../nostrClientFacade.js"; +import { getActiveSigner } from "../nostr/index.js"; +import { + publishEventToRelays as defaultPublishEventToRelays, + summarizePublishResults as defaultSummarize, +} from "../nostrPublish.js"; +import { ALLOW_NSFW_CONTENT } from "../config.js"; +import { userLogger } from "../utils/logger.js"; + +function str(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function createNip71MirrorService({ + buildMirrorEvent = buildNip71MirrorEvent, + getActivePubkey = () => str(nostrClient?.pubkey), + getSigner = () => getActiveSigner(), + // Outbox/write set (mirrors getDeletePublishRelays from the delete work). + getWriteRelays = () => + typeof nostrClient?.getDeletePublishRelays === "function" + ? nostrClient.getDeletePublishRelays() + : [], + getPool = () => nostrClient?.pool || null, + publishEventToRelays = defaultPublishEventToRelays, + summarizePublishResults = defaultSummarize, + signEvent = async (template) => { + const signer = getActiveSigner(); + if (!signer || typeof signer.signEvent !== "function") { + const err = new Error("Active signer missing signEvent support."); + err.code = "sign-event-missing"; + throw err; + } + return signer.signEvent(template); + }, + // Instance policy: only mirror NSFW outward when the site permits NSFW. + allowNsfw = () => ALLOW_NSFW_CONTENT === true, + now = () => Date.now(), +} = {}) { + function isAvailable() { + const pubkey = getActivePubkey(); + const signer = getSigner(); + return Boolean(pubkey) && Boolean(signer) && typeof signer.signEvent === "function"; + } + + // Whether a video is even eligible for the mirror (drives UI gating too). + function canMirror(video) { + if (!video || typeof video !== "object") { + return { ok: false, reason: "invalid" }; + } + if (video.isPrivate === true) { + return { ok: false, reason: "private" }; + } + if (video.isNsfw === true && !allowNsfw()) { + return { ok: false, reason: "nsfw-blocked" }; + } + if (!/^https:\/\//i.test(str(video.url))) { + return { ok: false, reason: "no-url" }; + } + return { ok: true }; + } + + async function publish(video, options = {}) { + if (!isAvailable()) { + return { ok: false, error: "unavailable" }; + } + // Site NSFW policy gate (defense in depth alongside the UI not offering it). + if (video?.isNsfw === true && !allowNsfw()) { + return { ok: false, reason: "nsfw-blocked" }; + } + + const pubkey = str(video?.pubkey) || getActivePubkey(); + const built = buildMirrorEvent( + { ...video, pubkey }, + { ...options, createdAt: Math.floor(now() / 1000) }, + ); + if (!built.ok) { + return { ok: false, reason: built.reason }; + } + + let signed; + try { + signed = await signEvent(built.event); + } catch (error) { + userLogger.warn("[nip71Mirror] Failed to sign mirror event:", error); + return { ok: false, error: "sign-failed", cause: error }; + } + + const relays = getWriteRelays() || []; + const pool = getPool(); + if (!pool || !relays.length) { + return { ok: false, error: "no-relays" }; + } + + const results = await publishEventToRelays(pool, relays, signed); + const { accepted, failed } = summarizePublishResults(results); + return { + ok: accepted.length > 0, + accepted: accepted.length, + total: accepted.length + failed.length, + event: signed, + }; + } + + // Tear down the mirror (on delete, or when the user toggles sync off). Belt-and- + // suspenders per the plan: a NIP-09 delete referencing BOTH addressable kinds + // (robust to a since-changed orientation), AND an empty-replace tombstone so + // clients that cache addressable events but ignore NIP-09 still see it cleared. + async function remove(video, options = {}) { + if (!isAvailable()) { + return { ok: false, error: "unavailable" }; + } + const pubkey = str(video?.pubkey) || getActivePubkey(); + const root = str(video?.videoRootId); + if (!pubkey || !root) { + return { ok: false, error: "invalid" }; + } + + const createdAt = Math.floor(now() / 1000); + const reason = str(options.reason) || "Removed NIP-71 mirror"; + + const deleteEvent = { + kind: 5, + pubkey, + created_at: createdAt, + content: reason, + tags: [ + ["a", `34235:${pubkey}:${root}`], + ["a", `34236:${pubkey}:${root}`], + ["k", "34235"], + ["k", "34236"], + ], + }; + + const width = Number(video?.width); + const height = Number(video?.height); + const portrait = + Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width; + const tombstoneKind = + options.short === true || (options.short !== false && portrait) ? 34236 : 34235; + const tombstone = { + kind: tombstoneKind, + pubkey, + created_at: createdAt, + // No imeta → nothing plays; title kept so the entry reads as removed. + tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]], + content: "", + }; + + const relays = getWriteRelays() || []; + const pool = getPool(); + if (!pool || !relays.length) { + return { ok: false, error: "no-relays" }; + } + + let publishedOk = 0; + let total = 0; + for (const template of [deleteEvent, tombstone]) { + total += 1; + let signed; + try { + signed = await signEvent(template); + } catch (error) { + userLogger.warn("[nip71Mirror] Failed to sign teardown event:", error); + continue; + } + const results = await publishEventToRelays(pool, relays, signed); + const { accepted } = summarizePublishResults(results); + if (accepted.length > 0) { + publishedOk += 1; + } + } + + return { ok: publishedOk > 0, published: publishedOk, total }; + } + + return { isAvailable, canMirror, publish, remove }; +} + +export const nip71MirrorService = createNip71MirrorService(); + +export default nip71MirrorService; diff --git a/js/services/nip71MirrorSync.js b/js/services/nip71MirrorSync.js new file mode 100644 index 00000000..9091afa0 --- /dev/null +++ b/js/services/nip71MirrorSync.js @@ -0,0 +1,139 @@ +// Keeps an opted-in NIP-71 mirror (kind 34235/36) in lockstep with the canonical +// kind-30078 video on edit/delete — the "no UX fuss" glue so the two events never +// drift. Extracted from nostrService (which is size-capped); called from its +// edit/delete handlers. All best-effort: a mirror failure must never break the +// core edit/delete. + +import { FEATURE_NIP71_MIRROR } from "../constants.js"; +import { nip71MirrorService } from "./nip71MirrorService.js"; +import { convertEventToVideo } from "../nostr/index.js"; +import { + isMirrorEnabled, + setMirrorEnabled, + isAutoShareEnabled, + resolveEditSync, + resolveDeleteSync, + resolvePublishSync, +} from "./nip71MirrorFlags.js"; +import { devLogger } from "../utils/logger.js"; + +// On publish of a NEW video, auto-mirror it when the account opted into +// "auto-share new public videos" and it's eligible. Sets the per-video flag so +// later edits/deletes keep it in sync. Best-effort. +export async function syncNip71MirrorAfterPublish(detail = {}) { + const pubkey = typeof detail?.pubkey === "string" ? detail.pubkey : ""; + const legacyEvent = detail?.result?.legacy; + if (!pubkey || !legacyEvent || typeof legacyEvent !== "object") { + return; + } + let video; + try { + video = convertEventToVideo(legacyEvent); + } catch (error) { + return; + } + if (!video || video.invalid || !video.videoRootId) { + return; + } + const withPubkey = { ...video, pubkey }; + const decision = resolvePublishSync({ + featureOn: FEATURE_NIP71_MIRROR, + autoShare: isAutoShareEnabled(pubkey), + eligible: nip71MirrorService.canMirror(withPubkey).ok, + }); + if (decision.action !== "publish") { + return; + } + try { + const result = await nip71MirrorService.publish(withPubkey); + if (result?.ok) { + setMirrorEnabled(pubkey, video.videoRootId, true); + } + } catch (error) { + devLogger.warn("[nip71MirrorSync] auto-share publish failed", error); + } +} + +export async function syncNip71MirrorAfterEdit({ updatedData, pubkey } = {}) { + if (!updatedData || typeof updatedData !== "object") { + return; + } + const videoRootId = + typeof updatedData.videoRootId === "string" ? updatedData.videoRootId : ""; + const authorPubkey = typeof pubkey === "string" ? pubkey : ""; + if (!videoRootId || !authorPubkey) { + return; + } + const video = { ...updatedData, pubkey: authorPubkey, videoRootId }; + const decision = resolveEditSync({ + featureOn: FEATURE_NIP71_MIRROR, + enabled: isMirrorEnabled(authorPubkey, videoRootId), + eligible: nip71MirrorService.canMirror(video).ok, + }); + if (decision.action === "none") { + return; + } + try { + if (decision.action === "publish") { + await nip71MirrorService.publish(video); + } else { + // "unshare": no longer eligible (e.g. flipped private) — pull it down. + await nip71MirrorService.remove(video); + setMirrorEnabled(authorPubkey, videoRootId, false); + } + } catch (error) { + devLogger.warn("[nip71MirrorSync] edit sync failed", error); + } +} + +// Drive the lifecycle off nostrService's existing "videos:edited"/"videos:deleted" +// events — so the size-capped nostrService needs no changes. Idempotent: a guard +// prevents double-registration. Returns an unsubscribe fn. +let registered = false; +export function initNip71MirrorSync(nostrService) { + if (!nostrService || typeof nostrService.on !== "function" || registered) { + return () => {}; + } + registered = true; + const offEdit = nostrService.on("videos:edited", ({ updatedData, pubkey } = {}) => { + void syncNip71MirrorAfterEdit({ updatedData, pubkey }); + }); + const offDelete = nostrService.on("videos:deleted", ({ videoRootId, video, pubkey } = {}) => { + void syncNip71MirrorAfterDelete({ videoRootId, video, pubkey }); + }); + const offPublish = nostrService.on("videos:published", (detail) => { + void syncNip71MirrorAfterPublish(detail); + }); + return () => { + registered = false; + if (typeof offEdit === "function") offEdit(); + if (typeof offDelete === "function") offDelete(); + if (typeof offPublish === "function") offPublish(); + }; +} + +export async function syncNip71MirrorAfterDelete({ videoRootId, video, pubkey } = {}) { + const root = typeof videoRootId === "string" ? videoRootId : ""; + const authorPubkey = typeof pubkey === "string" ? pubkey : ""; + if (!root || !authorPubkey) { + return; + } + const decision = resolveDeleteSync({ + featureOn: FEATURE_NIP71_MIRROR, + enabled: isMirrorEnabled(authorPubkey, root), + }); + if (decision.action === "none") { + return; + } + try { + await nip71MirrorService.remove({ + ...(video || {}), + videoRootId: root, + pubkey: authorPubkey, + }); + } catch (error) { + devLogger.warn("[nip71MirrorSync] delete sync failed", error); + } finally { + setMirrorEnabled(authorPubkey, root, false); + } +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 63c44fb1..2f04c9f1 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -17,6 +17,8 @@ import hashtagPreferences, { import NwcSettingsService from "../services/nwcSettingsService.js"; import nostrService from "../services/nostrService.js"; import storageService from "../services/storageService.js"; +import { initNip71MirrorSync } from "../services/nip71MirrorSync.js"; +import { createNip71IngestService } from "../services/nip71IngestService.js"; import watchHistoryService from "../watchHistoryService.js"; import r2Service from "../services/r2Service.js"; import s3UploadService from "../services/s3UploadService.js"; @@ -849,6 +851,29 @@ export default class ApplicationBootstrap { app.videoSubscription = subscription || null; }), ); + // Keep an opted-in NIP-71 mirror in lockstep on edit/delete (event-driven so + // nostrService stays untouched). + nostrUnsubscribes.push(initNip71MirrorSync(app.nostrService)); + + // Inbound NIP-71 ingest: pull video events published by other Nostr apps + // (scoped to whitelisted authors while whitelist mode is on) and surface + // them in the feed. Gated by FEATURE_NIP71_INGEST. Best-effort. + // + // Deferred until the native feed has rendered once: injecting foreign + // authors triggers the moderation/WoT stage to hydrate their social graph + // (kind 30000), which during cold-start competes with the initial feed load + // and can stall it. Starting after the first videos:updated keeps ingest off + // the critical path; a fallback timer covers the empty-feed case. + const nip71IngestService = createNip71IngestService({ + nostrClient, + nostrService: app.nostrService, + accessControl, + }); + app.nip71IngestService = nip71IngestService; + if (nip71IngestService.isAvailable()) { + nip71IngestService.startWhenFeedReady(); + nostrUnsubscribes.push(() => nip71IngestService.stop()); + } nostrUnsubscribes.push( app.nostrService.on("directMessages:notification", () => { void app.refreshUnreadDmIndicator({ reason: "dm-notification" }); diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index f4344088..fa618431 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1,6 +1,7 @@ // components/UploadModal.js import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; +import { probeVideoMetadata } from "../../utils/videoProbe.js"; import { MediaUploader } from "./mediaUploader.js"; import { devLogger, userLogger } from "../../utils/logger.js"; import { @@ -483,10 +484,35 @@ export class UploadModal { // --- Immediate Upload Handlers --- + // Best-effort: read the file's dimensions/duration so we can persist them + // (orientation → 34236 short selection + a future "shorts" feed). Never blocks + // upload; failures just leave the metadata unset. + async captureVideoMetadata(file) { + this.capturedMetadata = null; + if (!file || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + return; + } + const objectUrl = URL.createObjectURL(file); + try { + this.capturedMetadata = await probeVideoMetadata(objectUrl); + } catch (error) { + this.capturedMetadata = null; + } finally { + try { + URL.revokeObjectURL(objectUrl); + } catch (revokeError) { + // ignore + } + } + } + async handleVideoSelection(e) { const file = e.target.files?.[0]; if (!file) return; + // Probe dimensions/duration in the background while the upload proceeds. + void this.captureVideoMetadata(file); + if (!this.storageConfigured) { alert("Please configure storage before selecting a file."); e.target.value = ""; // Clear selection @@ -900,6 +926,12 @@ export class UploadModal { enableComments: this.toggles.comments?.checked || true, ...audienceFlags, + // Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives + // 34236 short selection + a future "shorts" feed (height > width). + width: this.capturedMetadata?.width || 0, + height: this.capturedMetadata?.height || 0, + duration: this.capturedMetadata?.duration || 0, + // These might be empty depending on mode, filled below url: "", magnet: "", diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 45035de5..2ef0992f 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -4,6 +4,22 @@ import { getApplication } from "../../applicationContext.js"; import { collapseUserVideos } from "./myVideosData.js"; import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js"; import { reconcileStorage } from "./myVideosReconcile.js"; +import { FEATURE_NIP71_MIRROR } from "../../constants.js"; +import { nip71MirrorService } from "../../services/nip71MirrorService.js"; +import { + isMirrorEnabled, + setMirrorEnabled, + resolveMirrorToggle, + isAutoShareEnabled, + setAutoShareEnabled, +} from "../../services/nip71MirrorFlags.js"; + +const MIRROR_REASON_TEXT = { + private: "Private videos can't be shared to other apps.", + "nsfw-blocked": "NSFW videos aren't shared from this instance.", + "no-url": "Needs a hosted (HTTPS) URL to appear on other apps.", + invalid: "This video can't be mirrored.", +}; // Maps a health severity to a status-pill style, mirroring // ProfileRelayController.applyStatusPill so the look stays consistent. @@ -34,6 +50,8 @@ export class MyVideosController { this.refreshBtn = null; this.orphansSection = null; this.orphanListEl = null; + this.autoShareRow = null; + this.autoShareInput = null; this.loading = false; this.publicBaseUrl = ""; this.pubkey = ""; @@ -49,6 +67,10 @@ export class MyVideosController { document.getElementById("profileMyVideosOrphans") || null; this.orphanListEl = document.getElementById("profileMyVideosOrphanList") || null; + this.autoShareRow = + document.getElementById("profileMyVideosAutoShareRow") || null; + this.autoShareInput = + document.getElementById("profileMyVideosAutoShare") || null; } registerEventListeners() { @@ -57,6 +79,26 @@ export class MyVideosController { void this.populate({ forceFetch: true }); }); } + if (this.autoShareInput instanceof HTMLInputElement) { + this.autoShareInput.addEventListener("change", () => { + const pubkey = this.getPubkey(); + if (!pubkey) { + return; + } + setAutoShareEnabled(pubkey, this.autoShareInput.checked === true); + }); + } + } + + refreshAutoShareToggle(pubkey) { + if (!(this.autoShareRow instanceof HTMLElement)) { + return; + } + const eligible = FEATURE_NIP71_MIRROR && !!pubkey; + this.autoShareRow.classList.toggle("hidden", !eligible); + if (eligible && this.autoShareInput instanceof HTMLInputElement) { + this.autoShareInput.checked = isAutoShareEnabled(pubkey); + } } getPubkey() { @@ -100,6 +142,7 @@ export class MyVideosController { return; } const pubkey = this.getPubkey(); + this.refreshAutoShareToggle(pubkey); if (!pubkey) { this.renderRows([]); this.setSummary(""); @@ -369,6 +412,10 @@ export class MyVideosController { actions.appendChild( this.buildActionButton("Edit", () => this.handleEdit(video)), ); + const mirrorBtn = this.buildMirrorButton(video); + if (mirrorBtn) { + actions.appendChild(mirrorBtn); + } actions.appendChild( this.buildActionButton("Delete", () => this.handleDelete(video), true), ); @@ -376,6 +423,72 @@ export class MyVideosController { return actions; } + // Opt-in "publish to other Nostr video apps (NIP-71)" toggle. Reflects the + // per-video opt-in flag; disabled (with a reason) for ineligible videos. + buildMirrorButton(video) { + if (!FEATURE_NIP71_MIRROR) { + return null; + } + const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); + const eligibility = nip71MirrorService.canMirror(video); + if (!enabled && eligibility.ok !== true) { + const reason = MIRROR_REASON_TEXT[eligibility.reason] || "Can't be shared."; + const btn = this.buildActionButton("Share off", () => { + this.mainController.showStatus?.(reason); + }); + btn.disabled = true; + btn.title = reason; + btn.classList.add("opacity-50"); + return btn; + } + return this.buildActionButton( + enabled ? "Shared ✓" : "Share to apps", + (event) => this.handleToggleMirror(video, event?.currentTarget || null), + ); + } + + async handleToggleMirror(video, btn) { + const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); + const eligibility = nip71MirrorService.canMirror(video); + const decision = resolveMirrorToggle({ enabled, eligibility }); + + if (decision.action === "blocked") { + this.mainController.showError?.( + MIRROR_REASON_TEXT[decision.reason] || "This video can't be shared.", + ); + return; + } + if (btn) { + btn.disabled = true; + } + try { + if (decision.action === "publish") { + const result = await nip71MirrorService.publish(video); + if (result?.ok) { + setMirrorEnabled(this.pubkey, video.videoRootId, true); + this.mainController.showSuccess?.( + `Shared to other Nostr apps (${result.accepted}/${result.total} relays).`, + ); + } else { + this.mainController.showError?.( + "Couldn't share this video. Please try again.", + ); + } + } else { + const result = await nip71MirrorService.remove(video); + setMirrorEnabled(this.pubkey, video.videoRootId, false); + this.mainController.showSuccess?.( + result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.", + ); + } + } catch (error) { + devLogger.warn("[myVideos] mirror toggle failed:", error); + this.mainController.showError?.("Sharing action failed. Please try again."); + } finally { + void this.populate({ forceFetch: true }); + } + } + buildActionButton(label, onClick, danger = false) { const btn = document.createElement("button"); btn.type = "button"; diff --git a/js/ui/urlHealthController.js b/js/ui/urlHealthController.js index a4503692..36c5b730 100644 --- a/js/ui/urlHealthController.js +++ b/js/ui/urlHealthController.js @@ -152,6 +152,21 @@ export default class UrlHealthController { this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId); + // Try the primary url plus any additional NIP-71 source mirrors, so one dead + // host doesn't mark a video offline when an alternate plays. + const sources = Array.isArray(video?.sources) ? video.sources : []; + const candidateUrls = []; + const seenUrls = new Set(); + for (const candidate of [ + trimmedUrl, + ...sources.map((s) => (s && typeof s.url === "string" ? s.url.trim() : "")), + ]) { + if (candidate && !seenUrls.has(candidate)) { + seenUrls.add(candidate); + candidateUrls.push(candidate); + } + } + const probeOptions = { confirmPlayable: true }; const getInFlight = this.state.getInFlightUrlProbe; const existingProbe = @@ -175,7 +190,7 @@ export default class UrlHealthController { return; } - const probePromise = this.probeUrl(trimmedUrl, probeOptions) + const probePromise = this.probeUrlList(candidateUrls, probeOptions) .then((result) => { const outcome = result?.outcome || "error"; let entry; @@ -341,6 +356,36 @@ export default class UrlHealthController { } } + // Probe several candidate URLs (mirrors) in order; "ok" as soon as one is + // playable, "timeout" if none played but at least one timed out, else + // "offline". Lets a video with a dead primary host survive on a live mirror. + async probeUrlList(urls, options = {}) { + const list = Array.isArray(urls) + ? urls.filter((u) => typeof u === "string" && u.trim()) + : []; + if (!list.length) { + return { outcome: "error" }; + } + let sawTimeout = false; + for (const url of list) { + let result; + try { + result = await this.probeUrl(url, options); + } catch (err) { + this.logger.warn(`[probeUrlList] probe threw for ${url}:`, err); + continue; + } + const outcome = result?.outcome; + if (outcome === "ok") { + return { outcome: "ok", url }; + } + if (outcome === "timeout") { + sawTimeout = true; + } + } + return { outcome: sawTimeout ? "timeout" : "offline" }; + } + async probeUrl(url, options = {}) { const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed) { diff --git a/js/utils/videoDeduper.js b/js/utils/videoDeduper.js index bfbe8c05..928d820d 100644 --- a/js/utils/videoDeduper.js +++ b/js/utils/videoDeduper.js @@ -34,4 +34,125 @@ export function dedupeToNewestByRoot(videos) { return Array.from(map.values()); } +// Identity keys that mark two events as "the same video" across ecosystems +// (bitvid kind-30078 vs a NIP-71 mirror/original). Scoped per author. Content +// hashes + infohash are the reliable signals; `importedFrom` (Phase 2 on-board +// provenance) links an on-boarded bitvid note to the original foreign event via a +// shared "eid" namespace. Videos with none of these only ever match themselves. +function crossEcosystemIdentityKeys(video) { + const author = + typeof video?.pubkey === "string" ? video.pubkey.trim().toLowerCase() : ""; + if (!author) { + return []; + } + const keys = []; + const push = (ns, value) => { + const s = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (s) { + keys.push(`${author}|${ns}|${s}`); + } + }; + push("sha", video.fileSha256); + push("ox", video.originalFileSha256); + push("ih", video.infoHash); + push("eid", video.id); + push("eid", video.importedFrom); + return keys; +} + +function isBitvidNative(video) { + return video?.source !== "nip71-ingest" && video?.foreign !== true; +} + +// Within a group of same-video events, prefer the bitvid-native (kind-30078) +// version; among the same kind, the newest (tie: higher id) for determinism. +function pickPreferredVideo(group) { + let best = null; + for (const video of group) { + if (!best) { + best = video; + continue; + } + const nativeV = isBitvidNative(video); + const nativeBest = isBitvidNative(best); + if (nativeV !== nativeBest) { + if (nativeV) best = video; + continue; + } + const tV = Number(video.created_at); + const tBest = Number(best.created_at); + const cV = Number.isFinite(tV) ? tV : -Infinity; + const cBest = Number.isFinite(tBest) ? tBest : -Infinity; + if (cV > cBest || (cV === cBest && String(video.id) > String(best.id))) { + best = video; + } + } + return best; +} + +/** + * Collapse duplicates of the same video that appear in BOTH ecosystems (a bitvid + * kind-30078 note and a NIP-71 mirror/original) so a grid never shows both. + * Always prefers the bitvid version. Same-ecosystem, distinct videos are + * untouched. Pure. + */ +export function collapseCrossEcosystem(videos) { + const list = Array.isArray(videos) ? videos.filter(Boolean) : []; + if (list.length < 2) { + return list.slice(); + } + + // Union-find: merge any videos that share an identity key. + const parent = list.map((_, i) => i); + const find = (i) => { + let root = i; + while (parent[root] !== root) root = parent[root]; + while (parent[i] !== root) { + const next = parent[i]; + parent[i] = root; + i = next; + } + return root; + }; + const union = (a, b) => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent[ra] = rb; + }; + + const keyToIndex = new Map(); + list.forEach((video, i) => { + for (const key of crossEcosystemIdentityKeys(video)) { + if (keyToIndex.has(key)) { + union(keyToIndex.get(key), i); + } else { + keyToIndex.set(key, i); + } + } + }); + + const groups = new Map(); + list.forEach((video, i) => { + const root = find(i); + const group = groups.get(root); + if (group) group.push(video); + else groups.set(root, [video]); + }); + + const result = []; + for (const group of groups.values()) { + result.push(group.length === 1 ? group[0] : pickPreferredVideo(group)); + } + return result; +} + +/** + * The shared grid dedupe: collapse to newest-per-root, THEN collapse + * cross-ecosystem duplicates (prefer bitvid). Use this everywhere a video list is + * built so every grid behaves identically. + */ +export function dedupeVideos(videos) { + return collapseCrossEcosystem(dedupeToNewestByRoot(videos)); +} + export default dedupeToNewestByRoot; diff --git a/js/utils/videoProbe.js b/js/utils/videoProbe.js new file mode 100644 index 00000000..0fdc6bbc --- /dev/null +++ b/js/utils/videoProbe.js @@ -0,0 +1,80 @@ +// Auto-capture a video's dimensions + duration by loading just its metadata into +// a hidden <video> element. Used at upload time so bitvid can persist width/height +// (orientation → 34236 short selection + a future "shorts" feed) and duration. +// +// Element creation is injectable so the resolution logic is unit-testable without +// a DOM or a real media file. Always resolves (never rejects): null on failure. + +export function probeVideoMetadata(src, { createVideoEl, timeoutMs = 8000 } = {}) { + return new Promise((resolve) => { + const source = typeof src === "string" ? src.trim() : ""; + if (!source) { + resolve(null); + return; + } + + let el = null; + try { + el = + typeof createVideoEl === "function" + ? createVideoEl() + : typeof document !== "undefined" && document.createElement + ? document.createElement("video") + : null; + } catch (error) { + el = null; + } + if (!el || typeof el.addEventListener !== "function") { + resolve(null); + return; + } + + let settled = false; + const cleanup = () => { + try { + el.removeAttribute?.("src"); + el.load?.(); + } catch (error) { + // ignore + } + }; + const finish = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + cleanup(); + resolve(result); + }; + + const timer = setTimeout(() => finish(null), timeoutMs); + + try { + el.preload = "metadata"; + el.muted = true; + } catch (error) { + // ignore (fakes may not allow these) + } + + el.addEventListener("loadedmetadata", () => { + const width = Number(el.videoWidth); + const height = Number(el.videoHeight); + const durationRaw = Number(el.duration); + finish({ + width: Number.isFinite(width) && width > 0 ? Math.floor(width) : 0, + height: Number.isFinite(height) && height > 0 ? Math.floor(height) : 0, + duration: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : 0, + }); + }); + el.addEventListener("error", () => finish(null)); + + try { + el.src = source; + } catch (error) { + finish(null); + } + }); +} + +export default probeVideoMetadata; diff --git a/tests/admin-list-batched-fetch.test.mjs b/tests/admin-list-batched-fetch.test.mjs new file mode 100644 index 00000000..8da47e4f --- /dev/null +++ b/tests/admin-list-batched-fetch.test.mjs @@ -0,0 +1,73 @@ +// Batching the community-blacklist curator fetch into a single multi-author REQ +// (instead of one kind-30000 REQ per curator — the cold-start relay storm). +// Tests the pure filter-builder + result-selector. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildBatchedReferenceFilter, + selectNewestEventsForReferences, +} from "../js/adminListBatch.js"; + +const A = "a".repeat(64); +const B = "b".repeat(64); + +function evt({ id, pubkey, d, created_at }) { + return { id, pubkey, created_at, tags: [["d", d]] }; +} + +test("builds one filter with all unique authors + d-tags", () => { + const filter = buildBatchedReferenceFilter([ + { authorHex: A, dTag: "community:blacklist" }, + { authorHex: B, dTag: "community:blacklist" }, + { authorHex: A, dTag: "community:blacklist" }, // dup + ]); + assert.deepEqual(filter.kinds, [30000]); + assert.deepEqual(filter.authors.sort(), [A, B]); + assert.deepEqual(filter["#d"], ["community:blacklist"]); +}); + +test("returns null when there are no usable references", () => { + assert.equal(buildBatchedReferenceFilter([]), null); + assert.equal(buildBatchedReferenceFilter([{ authorHex: "", dTag: "" }]), null); +}); + +test("selects the newest event per (author, d-tag) reference", () => { + const references = [ + { authorHex: A, dTag: "list-1" }, + { authorHex: B, dTag: "list-1" }, + ]; + const events = [ + evt({ id: "a-old", pubkey: A, d: "list-1", created_at: 100 }), + evt({ id: "a-new", pubkey: A, d: "list-1", created_at: 200 }), + evt({ id: "b-only", pubkey: B, d: "list-1", created_at: 50 }), + ]; + const matched = selectNewestEventsForReferences(events, references); + assert.deepEqual(matched.map((e) => e.id), ["a-new", "b-only"]); +}); + +test("discards cross-product events that aren't real references", () => { + // The batched filter (authors A,B × d-tags d1,d2) can return (A,d2) even + // though only (A,d1) and (B,d2) were requested. Those must be dropped. + const references = [ + { authorHex: A, dTag: "d1" }, + { authorHex: B, dTag: "d2" }, + ]; + const events = [ + evt({ id: "a-d1", pubkey: A, d: "d1", created_at: 10 }), + evt({ id: "a-d2-crossproduct", pubkey: A, d: "d2", created_at: 99 }), + evt({ id: "b-d2", pubkey: B, d: "d2", created_at: 10 }), + ]; + const matched = selectNewestEventsForReferences(events, references); + assert.deepEqual(matched.map((e) => e.id).sort(), ["a-d1", "b-d2"]); +}); + +test("tie-break on created_at uses higher event id (deterministic)", () => { + const references = [{ authorHex: A, dTag: "d1" }]; + const events = [ + evt({ id: "id-aaa", pubkey: A, d: "d1", created_at: 100 }), + evt({ id: "id-zzz", pubkey: A, d: "d1", created_at: 100 }), + ]; + const matched = selectNewestEventsForReferences(events, references); + assert.deepEqual(matched.map((e) => e.id), ["id-zzz"]); +}); diff --git a/tests/channel-profile-videos.test.mjs b/tests/channel-profile-videos.test.mjs new file mode 100644 index 00000000..36c73809 --- /dev/null +++ b/tests/channel-profile-videos.test.mjs @@ -0,0 +1,81 @@ +// Channel-profile wall must source BOTH native kind-30078 videos and the +// creator's NIP-71 videos (cross-posted via other apps), while skipping bitvid's +// own outbound mirrors so they don't duplicate the canonical note. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildChannelVideoFilters, + convertChannelEvent, +} from "../js/channelProfileVideos.js"; + +const PK = "a".repeat(64); + +test("builds a native (30078) filter AND a NIP-71 filter for the author", () => { + const filters = buildChannelVideoFilters(PK); + assert.equal(filters.length, 2); + + const native = filters.find((f) => f.kinds.includes(30078)); + assert.deepEqual(native.authors, [PK]); + assert.deepEqual(native["#t"], ["video"]); + + const nip71 = filters.find((f) => !f.kinds.includes(30078)); + assert.deepEqual(nip71.kinds.slice().sort((a, b) => a - b), [21, 22, 34235, 34236]); + assert.deepEqual(nip71.authors, [PK]); +}); + +test("converts a foreign NIP-71 event into a renderable video", () => { + const v = convertChannelEvent({ + id: "evt", + pubkey: PK, + kind: 34235, + created_at: 1000, + content: "desc", + tags: [ + ["d", "root"], + ["title", "Nostube clip"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }); + assert.equal(v.invalid, false); + assert.equal(v.title, "Nostube clip"); + assert.equal(v.source, "nip71-ingest"); +}); + +test("skips bitvid's own mirror (returns invalid so the caller drops it)", () => { + const v = convertChannelEvent({ + id: "mirror", + pubkey: PK, + kind: 34235, + created_at: 1000, + content: "", + tags: [ + ["d", "root"], + ["client", "bitvid"], + ["title", "Our mirror"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }); + assert.equal(v.invalid, true); + assert.equal(v.reason, "bitvid-mirror"); +}); + +test("routes kind-30078 events through the standard converter", () => { + const v = convertChannelEvent({ + id: "native", + pubkey: PK, + kind: 30078, + created_at: 2000, + content: JSON.stringify({ + version: 3, + title: "Native bitvid video", + videoRootId: "vr", + url: "https://example.com/native.mp4", + }), + tags: [["d", "vr"], ["t", "video"]], + }); + assert.equal(v.invalid, false); + assert.equal(v.title, "Native bitvid video"); + // Not stamped as ingested — it's a native note. + assert.notEqual(v.source, "nip71-ingest"); +}); diff --git a/tests/channel-video-cache.test.mjs b/tests/channel-video-cache.test.mjs new file mode 100644 index 00000000..b270cece --- /dev/null +++ b/tests/channel-video-cache.test.mjs @@ -0,0 +1,64 @@ +// Per-channel persisted cache: instant cold-load paint of a profile wall. +// Round-trips last-seen videos via localStorage, strips heavy tags, and bounds +// both per-channel count and number of cached channels. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + loadCachedChannelVideos, + saveCachedChannelVideos, +} from "../js/channelProfileVideos.js"; + +const PK = "a".repeat(64); + +test("round-trips videos for a channel, stripping raw tags", () => { + localStorage.clear(); + saveCachedChannelVideos(PK, [ + { id: "v1", title: "One", pubkey: PK, created_at: 1, url: "https://e/1.mp4", tags: [["e", "x"]] }, + ]); + const out = loadCachedChannelVideos(PK); + assert.equal(out.length, 1); + assert.equal(out[0].id, "v1"); + assert.equal(out[0].url, "https://e/1.mp4"); + assert.equal(out[0].tags, undefined, "heavy tags array not persisted"); +}); + +test("scoped per pubkey", () => { + localStorage.clear(); + saveCachedChannelVideos(PK, [{ id: "v1", title: "x", pubkey: PK, created_at: 1 }]); + assert.equal(loadCachedChannelVideos("b".repeat(64)).length, 0); +}); + +test("caps videos per channel (60)", () => { + localStorage.clear(); + const many = Array.from({ length: 100 }, (_, i) => ({ + id: `v${i}`, + title: `t${i}`, + pubkey: PK, + created_at: i, + })); + saveCachedChannelVideos(PK, many); + assert.equal(loadCachedChannelVideos(PK).length, 60); +}); + +test("evicts the least-recently-saved channel past the cap (20)", () => { + localStorage.clear(); + for (let i = 0; i < 22; i += 1) { + const pk = i.toString(16).padStart(64, "0"); + saveCachedChannelVideos(pk, [{ id: `v${i}`, title: "t", pubkey: pk, created_at: i }]); + } + // The two oldest channels (0,1) should have been evicted; the newest remains. + const oldest = "0".padStart(64, "0"); + const newest = (21).toString(16).padStart(64, "0"); + assert.equal(loadCachedChannelVideos(oldest).length, 0, "oldest evicted"); + assert.equal(loadCachedChannelVideos(newest).length, 1, "newest kept"); +}); + +test("missing / empty inputs are safe no-ops", () => { + localStorage.clear(); + saveCachedChannelVideos("", [{ id: "v" }]); // no pubkey + saveCachedChannelVideos(PK, null); // bad videos + assert.deepEqual(loadCachedChannelVideos(PK), []); + assert.deepEqual(loadCachedChannelVideos(""), []); +}); diff --git a/tests/cross-ecosystem-dedupe.test.mjs b/tests/cross-ecosystem-dedupe.test.mjs new file mode 100644 index 00000000..25df7501 --- /dev/null +++ b/tests/cross-ecosystem-dedupe.test.mjs @@ -0,0 +1,111 @@ +// Cross-ecosystem dedupe: when the same video exists as a bitvid kind-30078 note +// AND a NIP-71 mirror/original, show exactly one — always the bitvid version. +// This is the shared pass behind every grid (feed, channel, search, ...). + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collapseCrossEcosystem, + dedupeVideos, +} from "../js/utils/videoDeduper.js"; + +const PK = "a".repeat(64); +const SHA = "f".repeat(64); + +const bitvid = (over = {}) => ({ + id: "bitvid-id", + pubkey: PK, + videoRootId: "bitvid-root", + created_at: 100, + // native: no nip71-ingest source + ...over, +}); +const foreign = (over = {}) => ({ + id: "foreign-id", + pubkey: PK, + videoRootId: "foreign-root", + created_at: 200, + source: "nip71-ingest", + foreign: true, + ...over, +}); + +test("same file hash → keeps the bitvid version, drops the foreign one", () => { + const out = collapseCrossEcosystem([ + foreign({ fileSha256: SHA }), + bitvid({ fileSha256: SHA }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "bitvid-id"); +}); + +test("prefers bitvid even when the foreign version is NEWER", () => { + const out = collapseCrossEcosystem([ + bitvid({ fileSha256: SHA, created_at: 1 }), + foreign({ fileSha256: SHA, created_at: 9999 }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "bitvid-id"); +}); + +test("matches on infohash too", () => { + const ih = "1".repeat(40); + const out = collapseCrossEcosystem([ + foreign({ infoHash: ih }), + bitvid({ infoHash: ih }), + ]); + assert.deepEqual(out.map((v) => v.id), ["bitvid-id"]); +}); + +test("matches via on-board provenance link (importedFrom -> foreign event id)", () => { + const out = collapseCrossEcosystem([ + foreign({ id: "orig-evt" }), + bitvid({ id: "imported-note", importedFrom: "orig-evt" }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "imported-note"); +}); + +test("distinct videos (no shared identity) are NOT merged", () => { + const out = collapseCrossEcosystem([ + bitvid({ id: "v1", fileSha256: "1".repeat(64) }), + foreign({ id: "v2", fileSha256: "2".repeat(64) }), + ]); + assert.equal(out.length, 2); +}); + +test("hash-less videos are not merged (documented limitation)", () => { + const out = collapseCrossEcosystem([ + bitvid({ id: "v1" }), + foreign({ id: "v2" }), + ]); + assert.equal(out.length, 2); +}); + +test("two foreign versions of one file collapse to the newest (no bitvid present)", () => { + const out = collapseCrossEcosystem([ + foreign({ id: "old", fileSha256: SHA, created_at: 100 }), + foreign({ id: "new", fileSha256: SHA, created_at: 200 }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "new"); +}); + +test("dedupeVideos collapses by root THEN across ecosystems", () => { + // Two bitvid events same root (older + newer) + a foreign mirror by hash. + const out = dedupeVideos([ + bitvid({ id: "old", videoRootId: "r", created_at: 1, fileSha256: SHA }), + bitvid({ id: "new", videoRootId: "r", created_at: 2, fileSha256: SHA }), + foreign({ id: "mirror", fileSha256: SHA, created_at: 3 }), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].id, "new", "newest bitvid per root wins, foreign mirror dropped"); +}); + +test("different authors with the same hash are NOT merged (identity is per-author)", () => { + const out = collapseCrossEcosystem([ + bitvid({ id: "mine", pubkey: PK, fileSha256: SHA }), + foreign({ id: "theirs", pubkey: "b".repeat(64), fileSha256: SHA }), + ]); + assert.equal(out.length, 2); +}); diff --git a/tests/filter-hex-sanitize.test.mjs b/tests/filter-hex-sanitize.test.mjs new file mode 100644 index 00000000..a3701af7 --- /dev/null +++ b/tests/filter-hex-sanitize.test.mjs @@ -0,0 +1,64 @@ +// Sanitizing hex-only filter fields (ids/authors/#e/#p/#q) before they reach a +// relay. One odd-length/non-hex value otherwise makes strict relays reject the +// whole REQ ("uneven size input to from_hex"), silently dropping results. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { sanitizeHexFilterFields } from "../js/nostr/toolkit.js"; + +const HEX_A = "a".repeat(64); +const HEX_B = "b".repeat(64); +const ODD = "abc"; // odd length -> from_hex failure +const SHORT = "a".repeat(63); // wrong length + +test("strips invalid hex from authors but keeps valid entries", () => { + const out = sanitizeHexFilterFields({ kinds: [0], authors: [HEX_A, ODD, HEX_B] }); + assert.deepEqual(out.authors, [HEX_A, HEX_B]); + assert.deepEqual(out.kinds, [0], "non-hex fields untouched"); +}); + +test("lowercases and trims valid hex", () => { + const out = sanitizeHexFilterFields({ ids: [` ${HEX_A.toUpperCase()} `] }); + assert.deepEqual(out.ids, [HEX_A]); +}); + +test("drops the whole filter when a populated hex field becomes empty", () => { + // If every author was invalid, removing the key would broaden the query to + // "all authors" — drop the filter instead. + const out = sanitizeHexFilterFields({ kinds: [1], authors: [ODD, SHORT] }); + assert.equal(out, null); +}); + +test("sanitizes event/pubkey tag filters (#e/#p/#q)", () => { + const out = sanitizeHexFilterFields({ + kinds: [1984], + "#e": [HEX_A, ODD], + "#p": [HEX_B], + }); + assert.deepEqual(out["#e"], [HEX_A]); + assert.deepEqual(out["#p"], [HEX_B]); +}); + +test("leaves non-hex tag fields (#a/#d/#t) alone", () => { + const filter = { + kinds: [21, 22], + "#a": ["30078:" + HEX_A + ":some-d-tag"], + "#d": ["subscriptions"], + "#t": ["music"], + }; + const out = sanitizeHexFilterFields(filter); + assert.deepEqual(out["#a"], filter["#a"]); + assert.deepEqual(out["#d"], filter["#d"]); + assert.deepEqual(out["#t"], filter["#t"]); +}); + +test("returns the same object when nothing needs sanitizing (no needless copy)", () => { + const filter = { kinds: [0], authors: [HEX_A] }; + const out = sanitizeHexFilterFields(filter); + assert.equal(out, filter); +}); + +test("ignores empty/absent hex fields", () => { + const out = sanitizeHexFilterFields({ kinds: [1], authors: [] }); + assert.deepEqual(out, { kinds: [1], authors: [] }); +}); diff --git a/tests/lnurl-fetch-error.test.mjs b/tests/lnurl-fetch-error.test.mjs new file mode 100644 index 00000000..e3e96b53 --- /dev/null +++ b/tests/lnurl-fetch-error.test.mjs @@ -0,0 +1,47 @@ +// A recipient's Lightning endpoint being unreachable (down, or — common for +// LNURL — missing CORS headers) makes the browser fetch reject with +// "Failed to fetch". The zap flow should surface a clear, actionable message, +// not the raw error. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { fetchPayServiceData } from "../js/payments/lnurl.js"; + +test("network/CORS failure throws a friendly, coded error", async () => { + const fetcher = async () => { + throw new TypeError("Failed to fetch"); + }; + await assert.rejects( + () => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }), + (err) => { + assert.match(err.message, /reach this recipient's Lightning address/i); + assert.equal(err.code, "lnurl-unreachable"); + assert.ok(err.cause, "preserves the original error as cause"); + return true; + }, + ); +}); + +test("a non-OK HTTP response still reports a status error (unchanged)", async () => { + const fetcher = async () => ({ ok: false, status: 502 }); + await assert.rejects( + () => fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { fetcher }), + /Failed to load LNURL metadata \(502\)/, + ); +}); + +test("a valid LNURL pay response parses (no false friendly error)", async () => { + const fetcher = async () => ({ + ok: true, + json: async () => ({ + callback: "https://example.com/cb", + minSendable: 1000, + maxSendable: 100000, + tag: "payRequest", + }), + }); + const meta = await fetchPayServiceData("https://example.com/.well-known/lnurlp/x", { + fetcher, + }); + assert.equal(meta.callback, "https://example.com/cb"); +}); diff --git a/tests/nip71-ingest-adapter.test.mjs b/tests/nip71-ingest-adapter.test.mjs new file mode 100644 index 00000000..88f8fa8f --- /dev/null +++ b/tests/nip71-ingest-adapter.test.mjs @@ -0,0 +1,227 @@ +// Foreign NIP-71 video event -> bitvid video object adapter. Scenario-style: +// asserts the externally observable shape a feed item needs, and the safety +// behaviors (own-mirror skip, content-warning -> isNsfw, source provenance). + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildVideoFromNip71Event, + isBitvidMirrorEvent, + collectVideoSources, +} from "../js/nostr/nip71IngestAdapter.js"; + +const SHA = "a".repeat(64); + +function foreignEvent(overrides = {}) { + return { + id: "evt-1", + pubkey: "pk-foreign", + kind: 21, + created_at: 1_700_000_000, + content: "A description from another client", + tags: [ + ["title", "Foreign Video"], + [ + "imeta", + "url https://example.com/v.mp4", + "m video/mp4", + "dim 1920x1080", + "image https://example.com/thumb.jpg", + `x ${SHA}`, + ], + ["t", "music"], + ["duration", "240"], + ], + ...overrides, + }; +} + +test("maps a foreign NIP-71 event to a playable bitvid video", () => { + const v = buildVideoFromNip71Event(foreignEvent()); + assert.equal(v.invalid, false); + assert.equal(v.title, "Foreign Video"); + assert.equal(v.url, "https://example.com/v.mp4"); + assert.equal(v.thumbnail, "https://example.com/thumb.jpg"); + assert.equal(v.description, "A description from another client"); + assert.equal(v.width, 1920); + assert.equal(v.height, 1080); + assert.equal(v.duration, 240); + assert.deepEqual(v.hashtags, ["music"]); + assert.equal(v.fileSha256, SHA); + assert.equal(v.pubkey, "pk-foreign"); + // Provenance markers so the app can distinguish ingested content. + assert.equal(v.source, "nip71-ingest"); + assert.equal(v.foreign, true); + assert.equal(v.nip71Kind, 21); + // Never treat foreign content as private (no encryption in NIP-71). + assert.equal(v.isPrivate, false); +}); + +test("surfaces nip71.publishedAt so the feed skips per-video history fetches", () => { + // The published_at tag becomes nip71.publishedAt (feed resolve-posted-at uses + // it to avoid a blocking kind-30078 history fetch per ingested video). + const withPublished = buildVideoFromNip71Event( + foreignEvent({ + tags: [ + ["title", "Has published_at"], + ["published_at", "1700000123"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }), + ); + assert.equal(withPublished.nip71.publishedAt, 1700000123); + + // Falls back to created_at when there's no published_at tag. + const noPublished = buildVideoFromNip71Event(foreignEvent({ created_at: 1699999999 })); + assert.equal(noPublished.nip71.publishedAt, 1699999999); +}); + +test("content-warning maps to isNsfw so the NSFW gate applies", () => { + const clean = buildVideoFromNip71Event(foreignEvent()); + assert.equal(clean.isNsfw, false); + + const flagged = buildVideoFromNip71Event( + foreignEvent({ + tags: [ + ["title", "Spicy"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ["content-warning", "explicit"], + ], + }), + ); + assert.equal(flagged.isNsfw, true); +}); + +test("addressable kinds key the root on the d-tag; regular kinds on the event id", () => { + const regular = buildVideoFromNip71Event(foreignEvent({ id: "regular-id", kind: 22 })); + assert.equal(regular.videoRootId, "regular-id"); + + const addressable = buildVideoFromNip71Event( + foreignEvent({ + id: "addr-id", + kind: 34235, + tags: [ + ["d", "stable-root"], + ["title", "Addr"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }), + ); + assert.equal(addressable.videoRootId, "stable-root"); +}); + +test("skips bitvid's own outbound mirrors (avoids duplicate listings)", () => { + const mirror = foreignEvent({ + kind: 34235, + tags: [ + ["d", "root"], + ["client", "bitvid"], + ["title", "Our mirror"], + ["imeta", "url https://example.com/v.mp4", "m video/mp4"], + ], + }); + assert.equal(isBitvidMirrorEvent(mirror), true); + const v = buildVideoFromNip71Event(mirror); + assert.equal(v.invalid, true); + assert.equal(v.reason, "bitvid-mirror"); +}); + +test("rejects events with no playable source or no title", () => { + const noSource = buildVideoFromNip71Event( + foreignEvent({ tags: [["title", "No media"]] }), + ); + assert.equal(noSource.invalid, true); + assert.equal(noSource.reason, "missing playable source"); + + const noTitle = buildVideoFromNip71Event( + foreignEvent({ tags: [["imeta", "url https://example.com/v.mp4", "m video/mp4"]] }), + ); + assert.equal(noTitle.invalid, true); + assert.equal(noTitle.reason, "missing title"); +}); + +test("rejects non-NIP-71 kinds", () => { + const v = buildVideoFromNip71Event(foreignEvent({ kind: 30078 })); + assert.equal(v.invalid, true); + assert.equal(v.reason, "not a NIP-71 video kind"); +}); + +test("collectVideoSources keeps video urls in order, excludes audio/image, dedupes", () => { + const sources = collectVideoSources([ + { url: "https://a/v.mp4", m: "video/mp4", x: "a".repeat(64), dim: "1920x1080", duration: "120" }, + { url: "https://a/v.mp3", m: "audio/mpeg" }, + { url: "https://b/v.webm", m: "video/webm" }, + { url: "https://a/v.mp4", m: "video/mp4" }, // dup + { url: "https://c/untyped" }, // untyped accepted + { url: "https://a/thumb.jpg", m: "image/jpeg" }, + ]); + assert.deepEqual( + sources.map((s) => s.url), + ["https://a/v.mp4", "https://b/v.webm", "https://c/untyped"], + ); + assert.equal(sources[0].sha256, "a".repeat(64)); + assert.equal(sources[0].dim, "1920x1080"); + assert.equal(sources[0].duration, 120); +}); + +test("real Walker/podcast event (mp4 + mp3 imeta): one video source, mp3 excluded", () => { + const event = { + id: "a425040161016e64245282037c179bcb4aaaf1ce98024c9d7c59b32aabe1c3d2", + pubkey: "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc", + kind: 21, + created_at: 1781815633, + content: "Privacy isn't about having something to hide…", + tags: [ + ["title", "The Praxeology of Privacy"], + ["published_at", "1781815633"], + [ + "imeta", + "url https://relay.towardsliberty.com/84751f62.mp4", + "x 84751f62034f7396a75c0ba5096964cee2a4d65a076f655b43b6324b0fbefecd", + "m video/mp4", + "duration 5556", + "image https://relay.towardsliberty.com/a2e8159.jpg", + ], + [ + "imeta", + "url https://relay.towardsliberty.com/2885921.mp3", + "m audio/mpeg", + "duration 5556", + ], + ["t", "bitcoin"], + ], + }; + const v = buildVideoFromNip71Event(event); + assert.equal(v.invalid, false); + assert.equal(v.url, "https://relay.towardsliberty.com/84751f62.mp4"); + assert.equal(v.sources.length, 1, "only the video imeta becomes a source"); + assert.equal(v.sources[0].url, "https://relay.towardsliberty.com/84751f62.mp4"); +}); + +test("multiple video mirrors all become sources (fail-over)", () => { + const v = buildVideoFromNip71Event({ + id: "e", pubkey: "p", kind: 22, created_at: 1, + content: "", + tags: [ + ["title", "Mirrored"], + ["imeta", "url https://m1/v.mp4", "m video/mp4"], + ["imeta", "url https://m2/v.mp4", "m video/mp4"], + ], + }); + assert.deepEqual(v.sources.map((s) => s.url), ["https://m1/v.mp4", "https://m2/v.mp4"]); +}); + +test("extracts a webtorrent magnet + infohash when present", () => { + const magnet = `magnet:?xt=urn:btih:${"b".repeat(40)}&dn=clip`; + const v = buildVideoFromNip71Event( + foreignEvent({ + tags: [ + ["title", "Torrent clip"], + ["imeta", `magnet ${magnet}`, "m video/mp4", `i ${"c".repeat(40)}`], + ], + }), + ); + assert.equal(v.invalid, false); + assert.equal(v.magnet, magnet); + assert.equal(v.infoHash, "c".repeat(40), "prefers the imeta `i` infohash field"); +}); diff --git a/tests/nip71-ingest-service.test.mjs b/tests/nip71-ingest-service.test.mjs new file mode 100644 index 00000000..5db3c472 --- /dev/null +++ b/tests/nip71-ingest-service.test.mjs @@ -0,0 +1,305 @@ +// Inbound NIP-71 ingest service. Scenario-style with a virtualized nostrClient +// (in-memory activeMap/allEvents + a fake subscription manager) and a fake +// accessControl. Asserts observable outcomes: foreign videos land in the active +// store and trigger a refresh; native videos are never clobbered; the relay +// subscription is scoped to the whitelist; and FEATURE_NIP71_INGEST off makes +// the service inert. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNip71IngestService } from "../js/services/nip71IngestService.js"; + +const KINDS = [21, 22, 34235, 34236]; + +function activeKey(video) { + return video.videoRootId ? `ROOT:${video.videoRootId}` : `LEGACY:${video.id}`; +} + +function makeHarness({ whitelistOn = false, whitelist = [] } = {}) { + const activeMap = new Map(); + const allEvents = new Map(); + let capturedFilters = null; + let onEvent = null; + let closed = false; + let whitelistListener = null; + + const manager = { + subscribe(opts) { + capturedFilters = opts.filters; + onEvent = opts.onEvent; + return { + close() { + closed = true; + }, + update() {}, + }; + }, + }; + + const emitted = []; + const nostrClient = { + activeMap, + allEvents, + getActiveKey: activeKey, + getSubscriptionManager: () => manager, + }; + const feedListeners = new Set(); + const cached = []; + const nostrService = { + emit(name, detail) { + emitted.push({ name, detail }); + }, + on(name, fn) { + if (name === "videos:updated") { + feedListeners.add(fn); + } + return () => feedListeners.delete(fn); + }, + getFilteredActiveVideos: () => Array.from(activeMap.values()), + cacheVideos: (videos) => cached.push(...videos), + }; + const accessControl = { + whitelistMode: () => whitelistOn, + getWhitelistPubkeys: () => whitelist, + onWhitelistChange: (fn) => { + whitelistListener = fn; + return () => { + whitelistListener = null; + }; + }, + }; + + return { + activeMap, + allEvents, + emitted, + cached, + get capturedFilters() { + return capturedFilters; + }, + get closed() { + return closed; + }, + fireEvent: (e) => onEvent?.(e), + triggerWhitelistChange: () => whitelistListener?.(), + triggerFeedReady: () => feedListeners.forEach((fn) => fn({})), + nostrClient, + nostrService, + accessControl, + }; +} + +function foreignEvent(overrides = {}) { + return { + id: overrides.id || "evt-1", + pubkey: overrides.pubkey || "author-1", + kind: overrides.kind || 21, + created_at: overrides.created_at ?? 1000, + content: "desc", + tags: overrides.tags || [ + ["title", overrides.title || "Foreign"], + ["imeta", `url ${overrides.url || "https://e/v.mp4"}`, "m video/mp4"], + ], + }; +} + +function makeService(harness, extra = {}) { + return createNip71IngestService({ + nostrClient: harness.nostrClient, + nostrService: harness.nostrService, + accessControl: harness.accessControl, + featureEnabled: true, + flushDelayMs: 0, + // Disable the hydration retry by default so each test is deterministic and + // leaves no dangling timers; the retry behavior is covered explicitly below. + maxOpenAttempts: 0, + // Emit refreshes synchronously (no throttle timer) for deterministic tests. + refreshThrottleMs: 0, + ...extra, + }); +} + +test("ingests a foreign NIP-71 video into the active store and signals a refresh", () => { + const h = makeHarness(); + const svc = makeService(h); + assert.equal(svc.start(), true); + + h.fireEvent(foreignEvent({ id: "e1", title: "Hello" })); + const injected = svc.flush(); + + assert.equal(injected, 1); + const stored = h.activeMap.get("ROOT:e1"); + assert.ok(stored, "video present in activeMap"); + assert.equal(stored.title, "Hello"); + assert.equal(stored.source, "nip71-ingest"); + assert.ok(h.allEvents.has("e1"), "raw video cached in allEvents"); + + const refresh = h.emitted.find((e) => e.name === "videos:updated"); + assert.ok(refresh, "emits videos:updated"); + assert.equal(refresh.detail.reason, "nip71-ingest"); +}); + +test("caches injected videos into nostrService (persisted/author-feed paths)", () => { + const h = makeHarness(); + const svc = makeService(h); + svc.start(); + h.fireEvent(foreignEvent({ id: "e1", title: "Hello" })); + svc.flush(); + assert.equal(h.cached.length, 1, "injected video forwarded to cacheVideos"); + assert.equal(h.cached[0].id, "e1"); +}); + +test("never clobbers a native bitvid video sharing the same root", () => { + const h = makeHarness(); + const native = { id: "native", videoRootId: "shared", source: "nostr", created_at: 500, title: "Native" }; + h.activeMap.set("ROOT:shared", native); + + const svc = makeService(h); + svc.start(); + h.fireEvent(foreignEvent({ id: "foreign", created_at: 9999, tags: [["d", "shared"], ["title", "Foreign"], ["imeta", "url https://e/v.mp4", "m video/mp4"], ], kind: 34235 })); + const injected = svc.flush(); + + assert.equal(injected, 0, "native video is not overwritten even by a newer foreign event"); + assert.equal(h.activeMap.get("ROOT:shared"), native); +}); + +test("among ingested videos for one root, newest wins", () => { + const h = makeHarness(); + const svc = makeService(h); + svc.start(); + + h.fireEvent(foreignEvent({ id: "old", kind: 34235, created_at: 100, tags: [["d", "r"], ["title", "Old"], ["imeta", "url https://e/v1.mp4", "m video/mp4"]] })); + svc.flush(); + h.fireEvent(foreignEvent({ id: "new", kind: 34235, created_at: 200, tags: [["d", "r"], ["title", "New"], ["imeta", "url https://e/v2.mp4", "m video/mp4"]] })); + svc.flush(); + + assert.equal(h.activeMap.get("ROOT:r").title, "New"); + + // An older event for the same root must not resurrect. + h.fireEvent(foreignEvent({ id: "older", kind: 34235, created_at: 50, tags: [["d", "r"], ["title", "Older"], ["imeta", "url https://e/v3.mp4", "m video/mp4"]] })); + svc.flush(); + assert.equal(h.activeMap.get("ROOT:r").title, "New", "older event does not override newer"); +}); + +test("scopes the relay subscription to whitelisted authors when whitelist mode is on", () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa", "bb"] }); + const svc = makeService(h); + assert.equal(svc.start(), true); + + const filters = h.capturedFilters; + assert.ok(Array.isArray(filters) && filters.length === 1); + assert.deepEqual(filters[0].kinds.slice().sort((a, b) => a - b), KINDS); + assert.deepEqual(filters[0].authors, ["aa", "bb"], "subscribes only to whitelisted authors"); +}); + +test("with whitelist on but no authors, does not open a subscription (nothing to ingest)", () => { + const h = makeHarness({ whitelistOn: true, whitelist: [] }); + const svc = makeService(h); + assert.equal(svc.start(), false, "no subscription opened"); + assert.equal(h.capturedFilters, null); +}); + +test("opens an unscoped (capped) subscription when whitelist mode is off", () => { + const h = makeHarness({ whitelistOn: false }); + const svc = makeService(h); + svc.start(); + const filters = h.capturedFilters; + assert.ok(filters[0].authors === undefined, "no author scope when whitelist disabled"); + assert.ok(Number.isFinite(filters[0].limit), "still capped by a limit"); +}); + +test("retries until the whitelist hydrates, then subscribes (no missed-emit race)", async () => { + // Simulates the real startup race: whitelist mode is on but the hex author + // set isn't populated yet at start(), and no change event is emitted. + const h = makeHarness({ whitelistOn: true, whitelist: [] }); + const svc = makeService(h, { maxOpenAttempts: 5, openRetryDelayMs: 5 }); + assert.equal(svc.start(), false, "cannot open yet — authors not hydrated"); + assert.equal(h.capturedFilters, null); + + // Authors hydrate a moment later, WITHOUT firing onWhitelistChange. + h.accessControl.getWhitelistPubkeys = () => ["aa", "bb"]; + + // Wait past a retry tick. + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.ok(h.capturedFilters, "retry opened the subscription after hydration"); + assert.deepEqual(h.capturedFilters[0].authors, ["aa", "bb"]); + svc.stop(); +}); + +test("re-subscribes when the whitelist changes", () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h); + svc.start(); + assert.deepEqual(h.capturedFilters[0].authors, ["aa"]); + + // Whitelist grows; the access-control change fires. + h.accessControl.getWhitelistPubkeys = () => ["aa", "cc"]; + h.triggerWhitelistChange(); + assert.deepEqual(h.capturedFilters[0].authors, ["aa", "cc"], "subscription re-scoped after change"); +}); + +test("startWhenFeedReady defers subscription until the native feed renders", () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h, { feedReadyFallbackMs: 100000 }); + + assert.equal(svc.startWhenFeedReady(), true); + assert.equal(h.capturedFilters, null, "does not subscribe during cold-start"); + + // Native feed renders -> first videos:updated -> ingest starts now. + h.triggerFeedReady(); + assert.ok(h.capturedFilters, "subscribes once the feed is ready"); + assert.deepEqual(h.capturedFilters[0].authors, ["aa"]); + svc.stop(); +}); + +test("startWhenFeedReady falls back to starting if the feed never emits", async () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h, { feedReadyFallbackMs: 5 }); + svc.startWhenFeedReady(); + assert.equal(h.capturedFilters, null); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.ok(h.capturedFilters, "fallback timer started ingest"); + svc.stop(); +}); + +test("coalesces refresh signals so an event burst can't storm the feed", () => { + const h = makeHarness(); + const svc = makeService(h, { refreshThrottleMs: 10000 }); + svc.start(); + + h.fireEvent(foreignEvent({ id: "a", kind: 34235, tags: [["d", "ra"], ["title", "A"], ["imeta", "url https://e/a.mp4", "m video/mp4"]] })); + svc.flush(); + h.fireEvent(foreignEvent({ id: "b", kind: 34235, tags: [["d", "rb"], ["title", "B"], ["imeta", "url https://e/b.mp4", "m video/mp4"]] })); + svc.flush(); + + const refreshes = h.emitted.filter((e) => e.name === "videos:updated"); + assert.equal(refreshes.length, 1, "only one immediate refresh within the throttle window"); + // Both videos still landed in the store regardless of the throttled signal. + assert.ok(h.activeMap.has("ROOT:ra") && h.activeMap.has("ROOT:rb")); + svc.stop(); +}); + +test("FEATURE_NIP71_INGEST off makes the service completely inert", () => { + const h = makeHarness(); + const svc = createNip71IngestService({ + nostrClient: h.nostrClient, + nostrService: h.nostrService, + accessControl: h.accessControl, + featureEnabled: false, + flushDelayMs: 0, + }); + assert.equal(svc.isAvailable(), false); + assert.equal(svc.start(), false); + assert.equal(h.capturedFilters, null, "no relay subscription when ingest is disabled"); +}); + +test("stop() closes the subscription and unhooks the whitelist listener", () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h); + svc.start(); + svc.stop(); + assert.equal(h.closed, true, "subscription closed"); + // After stop, a whitelist change must not reopen anything. + h.triggerWhitelistChange(); +}); diff --git a/tests/nip71-mirror-flags.test.mjs b/tests/nip71-mirror-flags.test.mjs new file mode 100644 index 00000000..62318e16 --- /dev/null +++ b/tests/nip71-mirror-flags.test.mjs @@ -0,0 +1,94 @@ +// Per-video NIP-71 mirror opt-in: persistence (per pubkey+videoRootId) and the +// pure toggle-decision used by the My Videos tab. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isMirrorEnabled, + setMirrorEnabled, + resolveMirrorToggle, + resolveEditSync, + resolveDeleteSync, + isAutoShareEnabled, + setAutoShareEnabled, + resolvePublishSync, +} from "../js/services/nip71MirrorFlags.js"; + +const PK = "a".repeat(64); + +test("opt-in flag persists per pubkey+videoRootId and is off by default", () => { + localStorage.clear(); + assert.equal(isMirrorEnabled(PK, "root-1"), false, "off by default"); + + setMirrorEnabled(PK, "root-1", true); + assert.equal(isMirrorEnabled(PK, "root-1"), true); + assert.equal(isMirrorEnabled(PK, "root-2"), false, "scoped per videoRootId"); + assert.equal(isMirrorEnabled("b".repeat(64), "root-1"), false, "scoped per pubkey"); + + setMirrorEnabled(PK, "root-1", false); + assert.equal(isMirrorEnabled(PK, "root-1"), false, "can be turned back off"); +}); + +test("resolveMirrorToggle: enabled => remove", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: true, eligibility: { ok: true } }), + { action: "remove" }, + ); + // remove even if currently ineligible (so a now-private video can be pulled). + assert.deepEqual( + resolveMirrorToggle({ enabled: true, eligibility: { ok: false, reason: "private" } }), + { action: "remove" }, + ); +}); + +test("resolveMirrorToggle: off + eligible => publish", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: false, eligibility: { ok: true } }), + { action: "publish" }, + ); +}); + +test("resolveMirrorToggle: off + ineligible => blocked with reason", () => { + assert.deepEqual( + resolveMirrorToggle({ enabled: false, eligibility: { ok: false, reason: "no-url" } }), + { action: "blocked", reason: "no-url" }, + ); + assert.deepEqual( + resolveMirrorToggle({ enabled: false }), + { action: "blocked", reason: "ineligible" }, + ); +}); + +test("resolveEditSync: keeps a mirrored video in lockstep, unshares when ineligible", () => { + // not mirrored, or feature off → do nothing + assert.deepEqual(resolveEditSync({ featureOn: true, enabled: false, eligible: true }), { action: "none" }); + assert.deepEqual(resolveEditSync({ featureOn: false, enabled: true, eligible: true }), { action: "none" }); + // mirrored + still eligible → re-publish (no drift) + assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: true }), { action: "publish" }); + // mirrored but became ineligible (e.g. now private) → pull it down + assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: false }), { action: "unshare" }); +}); + +test("resolveDeleteSync: removes the mirror only when it was shared", () => { + assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: true }), { action: "unshare" }); + assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "none" }); + assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: true }), { action: "none" }); +}); + +test("auto-share preference persists per pubkey, off by default", () => { + localStorage.clear(); + assert.equal(isAutoShareEnabled(PK), false); + setAutoShareEnabled(PK, true); + assert.equal(isAutoShareEnabled(PK), true); + assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey"); + setAutoShareEnabled(PK, false); + assert.equal(isAutoShareEnabled(PK), false); +}); + +test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => { + assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: true }), { action: "publish" }); + assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: true, eligible: false }), { action: "none" }); + assert.deepEqual(resolvePublishSync({ featureOn: true, autoShare: false, eligible: true }), { action: "none" }); + assert.deepEqual(resolvePublishSync({ featureOn: false, autoShare: true, eligible: true }), { action: "none" }); +}); diff --git a/tests/nip71-mirror-service.test.mjs b/tests/nip71-mirror-service.test.mjs new file mode 100644 index 00000000..ab9718cd --- /dev/null +++ b/tests/nip71-mirror-service.test.mjs @@ -0,0 +1,147 @@ +// Phase 1: the NIP-71 mirror publish service. Scenarios assert the observable +// outcomes at the publish boundary, especially the site-wide NSFW moderation gate +// (ALLOW_NSFW_CONTENT) — an instance that won't surface NSFW must not publish it +// outward — plus the private / url / availability gates and a real publish. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNip71MirrorService } from "../js/services/nip71MirrorService.js"; +import { + publishEventToRelays, + summarizePublishResults, +} from "../js/nostrPublish.js"; + +const PUBKEY = "a".repeat(64); + +function baseVideo(overrides = {}) { + return { + videoRootId: "root-1", + pubkey: PUBKEY, + title: "Hello", + description: "desc", + url: "https://cdn.example.com/v.mp4", + thumbnail: "https://cdn.example.com/t.jpg", + isPrivate: false, + isNsfw: false, + ...overrides, + }; +} + +// Faithful relay twin: stores published events; thenable publish => success path. +function makeRelay() { + const events = []; + return { + events, + publish(_urls, event) { + events.push(event); + return Promise.resolve(); + }, + }; +} + +function makeService({ allowNsfw = false, available = true } = {}) { + const relay = makeRelay(); + const signer = available + ? { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) } + : null; + const service = createNip71MirrorService({ + getActivePubkey: () => (available ? PUBKEY : ""), + getSigner: () => signer, + getWriteRelays: () => ["wss://write.relay"], + getPool: () => relay, + publishEventToRelays, + summarizePublishResults, + signEvent: signer ? signer.signEvent : async () => { throw new Error("no signer"); }, + allowNsfw: () => allowNsfw === true, + }); + return { service, relay }; +} + +test("publishes a public video as an addressable 34235 mirror to the write relays", async () => { + const { service, relay } = makeService(); + const result = await service.publish(baseVideo()); + assert.equal(result.ok, true); + assert.equal(result.accepted, 1); + assert.equal(relay.events.length, 1); + assert.equal(relay.events[0].kind, 34235); + assert.equal(relay.events[0].tags.find((t) => t[0] === "d")[1], "root-1"); +}); + +test("NSFW video is NOT mirrored when the instance forbids NSFW (ALLOW_NSFW_CONTENT=false)", async () => { + const { service, relay } = makeService({ allowNsfw: false }); + const result = await service.publish(baseVideo({ isNsfw: true })); + assert.equal(result.ok, false); + assert.equal(result.reason, "nsfw-blocked"); + assert.equal(relay.events.length, 0, "must not publish NSFW outward"); + assert.deepEqual(service.canMirror(baseVideo({ isNsfw: true })), { + ok: false, + reason: "nsfw-blocked", + }); +}); + +test("NSFW video IS mirrored when the instance allows NSFW", async () => { + const { service, relay } = makeService({ allowNsfw: true }); + const result = await service.publish(baseVideo({ isNsfw: true, nsfwReason: "mature" })); + assert.equal(result.ok, true); + assert.equal(relay.events.length, 1); + assert.equal( + relay.events[0].tags.find((t) => t[0] === "content-warning")[1], + "mature", + ); +}); + +test("private and url-less videos are refused (builder rules)", async () => { + const { service, relay } = makeService(); + assert.equal((await service.publish(baseVideo({ isPrivate: true }))).reason, "private"); + assert.equal((await service.publish(baseVideo({ url: "" }))).reason, "no-url"); + assert.equal(relay.events.length, 0); +}); + +test("publish is unavailable without a signer / pubkey", async () => { + const { service } = makeService({ available: false }); + assert.equal(service.isAvailable(), false); + const result = await service.publish(baseVideo()); + assert.equal(result.ok, false); + assert.equal(result.error, "unavailable"); +}); + +test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => { + const { service, relay } = makeService(); + const result = await service.remove(baseVideo()); + assert.equal(result.ok, true); + assert.equal(relay.events.length, 2, "delete + empty-replace tombstone"); + + const del = relay.events.find((e) => e.kind === 5); + assert.ok(del, "must publish a NIP-09 kind-5 delete"); + const aTags = del.tags.filter((t) => t[0] === "a").map((t) => t[1]).sort(); + assert.deepEqual(aTags, [ + `34235:${PUBKEY}:root-1`, + `34236:${PUBKEY}:root-1`, + ], "delete references both addressable kinds (orientation-robust)"); + const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort(); + assert.deepEqual(kTags, ["34235", "34236"]); + + const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); + assert.ok(tomb, "must publish an empty-replace tombstone"); + assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag"); + assert.equal( + tomb.tags.some((t) => t[0] === "imeta"), + false, + "tombstone has no playable imeta", + ); +}); + +test("remove() tombstone uses the short kind for portrait videos", async () => { + const { service, relay } = makeService(); + await service.remove(baseVideo({ width: 1080, height: 1920 })); + const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236); + assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone"); +}); + +test("remove() is unavailable without a signer", async () => { + const { service } = makeService({ available: false }); + const result = await service.remove(baseVideo()); + assert.equal(result.ok, false); + assert.equal(result.error, "unavailable"); +}); diff --git a/tests/nip71-mirror-sync.test.mjs b/tests/nip71-mirror-sync.test.mjs new file mode 100644 index 00000000..f76d210e --- /dev/null +++ b/tests/nip71-mirror-sync.test.mjs @@ -0,0 +1,46 @@ +// Auto-sync glue: keeps an opted-in NIP-71 mirror in lockstep on edit/delete, +// driven by nostrService's existing videos:edited/videos:deleted events (so the +// size-capped nostrService is untouched). + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + syncNip71MirrorAfterDelete, + initNip71MirrorSync, +} from "../js/services/nip71MirrorSync.js"; +import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js"; + +const PK = "a".repeat(64); + +test("deleting a SHARED video clears its opt-in flag (mirror torn down)", async () => { + localStorage.clear(); + setMirrorEnabled(PK, "root-x", true); + await syncNip71MirrorAfterDelete({ videoRootId: "root-x", video: {}, pubkey: PK }); + assert.equal(isMirrorEnabled(PK, "root-x"), false, "flag cleared after delete"); +}); + +test("deleting a NON-shared video is a no-op (and never throws)", async () => { + localStorage.clear(); + await syncNip71MirrorAfterDelete({ videoRootId: "root-y", video: {}, pubkey: PK }); + assert.equal(isMirrorEnabled(PK, "root-y"), false); +}); + +test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => { + const events = {}; + const svc = { + on(name, handler) { + (events[name] = events[name] || []).push(handler); + return () => {}; + }, + }; + const off = initNip71MirrorSync(svc); + assert.equal(events["videos:edited"]?.length, 1, "subscribes to videos:edited"); + assert.equal(events["videos:deleted"]?.length, 1, "subscribes to videos:deleted"); + assert.equal(events["videos:published"]?.length, 1, "subscribes to videos:published"); + + initNip71MirrorSync(svc); // guard prevents double-registration + assert.equal(events["videos:edited"].length, 1, "no double-register"); + + off(); // resets the guard for other tests +}); diff --git a/tests/nip71-mirror.test.mjs b/tests/nip71-mirror.test.mjs new file mode 100644 index 00000000..2acaea27 --- /dev/null +++ b/tests/nip71-mirror.test.mjs @@ -0,0 +1,164 @@ +// Phase 0 of the NIP-71 interop plan: bitvid video (kind 30078 shape) -> addressable +// NIP-71 mirror event (kind 34235/34236). Scenarios assert the observable event +// shape AND that bitvid's own NIP-71 parser reads it back (round-trip), plus the +// two hard rules (private never mirrored, HTTPS url required) and short selection. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildNip71MirrorEvent, + deriveVideoMime, + NIP71_NORMAL_VIDEO_KIND, + NIP71_SHORT_VIDEO_KIND, +} from "../js/nostr/nip71Mirror.js"; +import { extractNip71MetadataFromTags } from "../js/nostr/nip71.js"; + +const PUBKEY = "a".repeat(64); +const INFOHASH = "b".repeat(40); + +function baseVideo(overrides = {}) { + return { + videoRootId: "root-123", + pubkey: PUBKEY, + title: "Crystalline Bliss", + description: "A lo-fi voxel puzzle journey.", + url: "https://cdn.example.com/u/abc/video.mp4", + thumbnail: "https://cdn.example.com/u/abc/thumb.jpg", + magnet: `magnet:?xt=urn:btih:${INFOHASH}&ws=https://cdn.example.com/u/abc/video.mp4`, + infoHash: INFOHASH, + fileSha256: "c".repeat(64), + originalFileSha256: "d".repeat(64), + isPrivate: false, + isNsfw: false, + hashtags: ["gamestr", "voxel"], + duration: 123, + ...overrides, + }; +} + +// Find the imeta tag and parse "key value" entries into an object (last wins, +// images/magnet collected). +function imetaFields(event) { + const tag = event.tags.find((t) => t[0] === "imeta"); + assert.ok(tag, "event must have an imeta tag"); + const fields = {}; + for (let i = 1; i < tag.length; i += 1) { + const sp = tag[i].indexOf(" "); + const k = tag[i].slice(0, sp); + const v = tag[i].slice(sp + 1); + fields[k] = v; + } + return fields; +} + +function tagValue(event, name) { + const tag = event.tags.find((t) => t[0] === name); + return tag ? tag[1] : undefined; +} + +test("maps a public bitvid video to a spec-shaped addressable 34235 event", async () => { + const res = buildNip71MirrorEvent(baseVideo(), { createdAt: 1000, publishedAt: 900 }); + assert.equal(res.ok, true); + const ev = res.event; + + assert.equal(ev.kind, NIP71_NORMAL_VIDEO_KIND, "landscape/default => 34235"); + assert.equal(ev.pubkey, PUBKEY); + assert.equal(ev.content, "A lo-fi voxel puzzle journey."); + + // Required + addressable identity + assert.equal(tagValue(ev, "d"), "root-123", "addressable d-tag = videoRootId"); + assert.equal(tagValue(ev, "title"), "Crystalline Bliss"); + assert.equal(tagValue(ev, "published_at"), "900", "stable first-publish time"); + + // Back-pointer + attribution + hashtags + assert.equal(tagValue(ev, "a"), `30078:${PUBKEY}:root-123`, "links to canonical 30078"); + const origin = ev.tags.find((t) => t[0] === "origin"); + assert.deepEqual(origin.slice(0, 3), ["origin", "bitvid", "root-123"]); + const tTags = ev.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort(); + assert.deepEqual(tTags, ["gamestr", "voxel"]); + + // imeta: HTTPS url + standard NIP-94 magnet/infohash/hashes + const f = imetaFields(ev); + assert.equal(f.url, "https://cdn.example.com/u/abc/video.mp4"); + assert.equal(f.m, "video/mp4"); + assert.equal(f.image, "https://cdn.example.com/u/abc/thumb.jpg"); + assert.equal(f.x, "c".repeat(64)); + assert.equal(f.ox, "d".repeat(64)); + assert.equal(f.i, INFOHASH, "infohash rides standard NIP-94 'i'"); + assert.ok(f.magnet.startsWith("magnet:?xt=urn:btih:"), "magnet rides standard NIP-94 'magnet'"); + assert.equal(f.duration, "123"); +}); + +test("round-trips: the produced event parses back via bitvid's NIP-71 parser", async () => { + const res = buildNip71MirrorEvent(baseVideo()); + const parsed = extractNip71MetadataFromTags(res.event); + assert.ok(parsed, "parser must read the mirror event"); + const meta = parsed.metadata; + assert.equal(meta.kind, NIP71_NORMAL_VIDEO_KIND, "addressable kind preserved"); + assert.equal(parsed.source.kind, NIP71_NORMAL_VIDEO_KIND, "source kind preserved"); + assert.equal(meta.title, "Crystalline Bliss"); + assert.deepEqual(meta.hashtags.sort(), ["gamestr", "voxel"]); + const v = meta.imeta[0]; + assert.equal(v.url, "https://cdn.example.com/u/abc/video.mp4"); + assert.equal(v.magnet, baseVideo().magnet, "magnet survives the round-trip"); + assert.equal(v.i, INFOHASH, "infohash survives the round-trip"); + assert.equal(v.ox, "d".repeat(64), "original-file hash survives the round-trip"); +}); + +test("private videos are NEVER mirrored", async () => { + const res = buildNip71MirrorEvent(baseVideo({ isPrivate: true })); + assert.equal(res.ok, false); + assert.equal(res.reason, "private"); +}); + +test("a video without an HTTPS url cannot be mirrored", async () => { + // magnet-only: bitvid plays it, foreign clients can't. + const res = buildNip71MirrorEvent(baseVideo({ url: "" })); + assert.equal(res.ok, false); + assert.equal(res.reason, "no-url"); + + const httpRes = buildNip71MirrorEvent(baseVideo({ url: "http://insecure.example/v.mp4" })); + assert.equal(httpRes.ok, false, "non-HTTPS url is refused"); +}); + +test("portrait dimensions select the short kind 34236; content-warning for NSFW", async () => { + const res = buildNip71MirrorEvent( + baseVideo({ width: 1080, height: 1920, isNsfw: true, nsfwReason: "mature" }), + ); + assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236 short"); + assert.equal(tagValue(res.event, "content-warning"), "mature"); + assert.equal(imetaFields(res.event).dim, "1080x1920"); +}); + +test("explicit short flag overrides dimension inference", async () => { + const landscapeButShort = buildNip71MirrorEvent( + baseVideo({ width: 1920, height: 1080 }), + { short: true }, + ); + assert.equal(landscapeButShort.event.kind, NIP71_SHORT_VIDEO_KIND); +}); + +test("mirror sources hashtags from nip71.hashtags when video.hashtags is absent", async () => { + const v = baseVideo({ hashtags: undefined, nip71: { hashtags: ["alpha", "beta"] } }); + const res = buildNip71MirrorEvent(v); + const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]).sort(); + assert.deepEqual(tTags, ["alpha", "beta"]); +}); + +test("mirror sources hashtags from raw `t` tags, dropping the internal 'video' topic", async () => { + const v = baseVideo({ + hashtags: undefined, + tags: [["t", "video"], ["t", "Gamestr"], ["t", "gamestr"], ["d", "root-123"]], + }); + const res = buildNip71MirrorEvent(v); + const tTags = res.event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + assert.ok(!tTags.includes("video"), "must not leak the bitvid 'video' topic tag"); + assert.deepEqual(tTags, ["gamestr"], "deduped + lowercased"); +}); + +test("deriveVideoMime maps common extensions and defaults to mp4", async () => { + assert.equal(deriveVideoMime("https://x/y.webm"), "video/webm"); + assert.equal(deriveVideoMime("https://x/y.m3u8?token=1"), "application/x-mpegURL"); + assert.equal(deriveVideoMime("https://x/y"), "video/mp4"); +}); diff --git a/tests/url-health-multisource.test.mjs b/tests/url-health-multisource.test.mjs new file mode 100644 index 00000000..8d82cb93 --- /dev/null +++ b/tests/url-health-multisource.test.mjs @@ -0,0 +1,57 @@ +// Multi-source URL liveness: probe candidate mirrors in order — healthy as soon +// as one plays, offline only if all fail (so a dead primary host doesn't hide a +// video that has a working mirror). + +import assert from "node:assert/strict"; +import test from "node:test"; +import UrlHealthController from "../js/ui/urlHealthController.js"; + +function controllerWith(outcomesByUrl) { + const ctrl = new UrlHealthController({ logger: { warn() {} } }); + const probed = []; + ctrl.probeUrl = async (url) => { + probed.push(url); + return { outcome: outcomesByUrl[url] || "error" }; + }; + return { ctrl, probed }; +} + +test("returns ok on the first playable source and stops probing further", async () => { + const { ctrl, probed } = controllerWith({ + "https://dead/v.mp4": "error", + "https://live/v.mp4": "ok", + "https://other/v.mp4": "ok", + }); + const res = await ctrl.probeUrlList([ + "https://dead/v.mp4", + "https://live/v.mp4", + "https://other/v.mp4", + ]); + assert.equal(res.outcome, "ok"); + assert.equal(res.url, "https://live/v.mp4"); + assert.deepEqual(probed, ["https://dead/v.mp4", "https://live/v.mp4"], "stops at first ok"); +}); + +test("offline only when every source fails", async () => { + const { ctrl } = controllerWith({ + "https://a/v.mp4": "error", + "https://b/v.mp4": "error", + }); + const res = await ctrl.probeUrlList(["https://a/v.mp4", "https://b/v.mp4"]); + assert.equal(res.outcome, "offline"); +}); + +test("reports timeout if none played but at least one timed out", async () => { + const { ctrl } = controllerWith({ + "https://a/v.mp4": "error", + "https://b/v.mp4": "timeout", + }); + const res = await ctrl.probeUrlList(["https://a/v.mp4", "https://b/v.mp4"]); + assert.equal(res.outcome, "timeout"); +}); + +test("empty / invalid list returns error", async () => { + const { ctrl } = controllerWith({}); + assert.equal((await ctrl.probeUrlList([])).outcome, "error"); + assert.equal((await ctrl.probeUrlList(["", " "])).outcome, "error"); +}); diff --git a/tests/video-dimensions.test.mjs b/tests/video-dimensions.test.mjs new file mode 100644 index 00000000..30bf0e5d --- /dev/null +++ b/tests/video-dimensions.test.mjs @@ -0,0 +1,94 @@ +// Dimension capture: width/height/duration are persisted in the 30078 content, +// survive edits, parse back onto the video, and drive the NIP-71 mirror's +// normal(34235)-vs-short(34236) selection + imeta dim. (Foundation for a future +// "shorts" vertical feed, which filters on height > width.) + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + prepareVideoPublishPayload, + prepareVideoEditPayload, + normalizeVideoDimension, +} from "../js/nostr/videoPayloadBuilder.js"; +import { convertEventToVideo } from "../js/nostr/nip71.js"; +import { + buildNip71MirrorEvent, + NIP71_NORMAL_VIDEO_KIND, + NIP71_SHORT_VIDEO_KIND, +} from "../js/nostr/nip71Mirror.js"; + +const PUBKEY = "a".repeat(64); + +function publish(extra) { + return prepareVideoPublishPayload( + { title: "Hi", url: "https://cdn.example.com/v.mp4", ...extra }, + PUBKEY, + { timestamp: 1000 }, + ); +} + +test("normalizeVideoDimension: positive int or 0", () => { + assert.equal(normalizeVideoDimension(1920), 1920); + assert.equal(normalizeVideoDimension("1080"), 1080); + assert.equal(normalizeVideoDimension(0), 0); + assert.equal(normalizeVideoDimension(-5), 0); + assert.equal(normalizeVideoDimension("abc"), 0); +}); + +test("publish persists width/height/duration in content; parser exposes them", async () => { + const { event } = await publish({ + videoRootId: "root-1", + width: 1080, + height: 1920, + duration: 42.5, + }); + const content = JSON.parse(event.content); + assert.equal(content.width, 1080); + assert.equal(content.height, 1920); + assert.equal(content.duration, 42.5); + + const video = convertEventToVideo(event); + assert.equal(video.width, 1080); + assert.equal(video.height, 1920); + assert.equal(video.duration, 42.5); +}); + +test("a portrait (height>width) video mirrors as 34236 short with imeta dim", async () => { + const { event } = await publish({ videoRootId: "root-2", width: 1080, height: 1920 }); + const video = convertEventToVideo(event); + const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY }); + assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236"); + const imeta = res.event.tags.find((t) => t[0] === "imeta"); + assert.ok(imeta.some((e) => e === "dim 1080x1920"), "carries imeta dim"); +}); + +test("a landscape video mirrors as 34235 normal", async () => { + const { event } = await publish({ videoRootId: "root-3", width: 1920, height: 1080 }); + const video = convertEventToVideo(event); + const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY }); + assert.equal(res.event.kind, NIP71_NORMAL_VIDEO_KIND, "landscape => 34235"); +}); + +test("editing preserves dimensions when the field is omitted", () => { + const { contentObject } = prepareVideoEditPayload({ + baseEvent: { + id: "e1", + videoRootId: "root-9", + title: "Old", + url: "https://cdn.example.com/v.mp4", + width: 1280, + height: 720, + duration: 30, + created_at: 1000, + tags: [], + }, + originalEventStub: { id: "e1" }, + updatedData: { title: "New" }, + userPubkey: PUBKEY, + resolveEventDTag: () => "root-9", + }); + assert.equal(contentObject.width, 1280); + assert.equal(contentObject.height, 720); + assert.equal(contentObject.duration, 30); +}); diff --git a/tests/video-hashtags.test.mjs b/tests/video-hashtags.test.mjs new file mode 100644 index 00000000..a28bf1ed --- /dev/null +++ b/tests/video-hashtags.test.mjs @@ -0,0 +1,88 @@ +// Hashtags polish: a video's hashtags are persisted in the 30078 content AND +// emitted as `t` tags (so bitvid's feed scoring + relay queries see them), and +// round-trip back through convertEventToVideo onto `video.hashtags` (which the +// NIP-71 mirror builder then carries as `t` tags too). + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + sanitizeVideoHashtags, + prepareVideoPublishPayload, + prepareVideoEditPayload, +} from "../js/nostr/videoPayloadBuilder.js"; +import { convertEventToVideo } from "../js/nostr/nip71.js"; + +const PUBKEY = "a".repeat(64); + +function publish(extra) { + return prepareVideoPublishPayload( + { title: "Hello", url: "https://cdn.example.com/v.mp4", ...extra }, + PUBKEY, + { timestamp: 1000 }, + ); +} + +test("sanitizeVideoHashtags: normalizes, dedupes, drops the reserved 'video' tag", () => { + assert.deepEqual( + sanitizeVideoHashtags(["#GameStr", "gamestr", " Voxel ", "video", ""]), + ["gamestr", "voxel"], + ); + assert.deepEqual(sanitizeVideoHashtags("#ai, blender cartoon"), ["ai", "blender", "cartoon"]); + assert.deepEqual(sanitizeVideoHashtags(null), []); +}); + +test("publish payload persists hashtags in content AND emits matching `t` tags", async () => { + const { event } = await publish({ + videoRootId: "root-1", + hashtags: ["#GameStr", "voxel", "voxel"], + }); + + const content = JSON.parse(event.content); + assert.deepEqual(content.hashtags, ["gamestr", "voxel"], "stored in content (normalized/deduped)"); + + const tTags = event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + assert.ok(tTags.includes("gamestr")); + assert.ok(tTags.includes("voxel")); +}); + +test("round-trip: convertEventToVideo exposes video.hashtags from content", async () => { + const { event } = await publish({ videoRootId: "root-2", hashtags: ["nostr", "p2p"] }); + const video = convertEventToVideo(event); + assert.equal(video.invalid, false); + assert.deepEqual(video.hashtags.sort(), ["nostr", "p2p"]); +}); + +test("no hashtags → no content.hashtags", async () => { + const { event } = await publish({ videoRootId: "root-3" }); + const content = JSON.parse(event.content); + assert.equal(content.hashtags, undefined); +}); + +function edit(baseHashtags, updatedData) { + return prepareVideoEditPayload({ + baseEvent: { + id: "e1", + videoRootId: "root-9", + title: "Old", + url: "https://cdn.example.com/v.mp4", + hashtags: baseHashtags, + created_at: 1000, + tags: [], + }, + originalEventStub: { id: "e1" }, + updatedData, + userPubkey: PUBKEY, + resolveEventDTag: () => "root-9", + }); +} + +test("editing WITHOUT a hashtags field preserves the existing hashtags", () => { + const { contentObject } = edit(["alpha", "beta"], { title: "New title" }); + assert.deepEqual(contentObject.hashtags, ["alpha", "beta"], "not stripped on edit"); +}); + +test("editing WITH hashtags overrides them", () => { + const { contentObject } = edit(["alpha"], { hashtags: ["gamma", "delta"] }); + assert.deepEqual(contentObject.hashtags, ["gamma", "delta"]); +}); diff --git a/tests/video-probe.test.mjs b/tests/video-probe.test.mjs new file mode 100644 index 00000000..e0768cc0 --- /dev/null +++ b/tests/video-probe.test.mjs @@ -0,0 +1,59 @@ +// probeVideoMetadata resolves a video's dimensions/duration from a hidden element, +// or null on error/timeout. Uses an injected fake element so it's deterministic. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { probeVideoMetadata } from "../js/utils/videoProbe.js"; + +// Minimal fake <video>: fires the named event on the next tick when src is set. +function fakeVideo({ fire = "loadedmetadata", videoWidth = 0, videoHeight = 0, duration = 0 } = {}) { + const handlers = {}; + return { + videoWidth, + videoHeight, + duration, + addEventListener(name, h) { + handlers[name] = h; + }, + removeAttribute() {}, + load() {}, + set src(_v) { + setTimeout(() => handlers[fire]?.(), 0); + }, + }; +} + +test("resolves width/height/duration from loadedmetadata", async () => { + const result = await probeVideoMetadata("blob:abc", { + createVideoEl: () => fakeVideo({ videoWidth: 1080, videoHeight: 1920, duration: 12.3 }), + }); + assert.deepEqual(result, { width: 1080, height: 1920, duration: 12.3 }); +}); + +test("a portrait probe yields height > width (drives short selection)", async () => { + const r = await probeVideoMetadata("blob:abc", { + createVideoEl: () => fakeVideo({ videoWidth: 720, videoHeight: 1280 }), + }); + assert.ok(r.height > r.width); + assert.equal(r.duration, 0, "missing duration normalizes to 0"); +}); + +test("resolves null on a media error", async () => { + const r = await probeVideoMetadata("blob:bad", { + createVideoEl: () => fakeVideo({ fire: "error" }), + }); + assert.equal(r, null); +}); + +test("resolves null for an empty source and times out gracefully", async () => { + assert.equal(await probeVideoMetadata("", { createVideoEl: () => fakeVideo() }), null); + // never fires any event -> timeout path + const neverFires = { + addEventListener() {}, + removeAttribute() {}, + load() {}, + set src(_v) {}, + }; + const r = await probeVideoMetadata("blob:x", { createVideoEl: () => neverFires, timeoutMs: 20 }); + assert.equal(r, null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index ae55b88c..20f82d3c 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -94,11 +94,38 @@ tests → `npm run build` + `npm run test:unit` green → commit + push. wrapper, so add carefully — plus a feed/view-level seed→delete→assert test. (Local + publish/read layers are already unit+mutation tested.) -### 3. Zaps system + platform-fee zap split -- [ ] Audit the zap flow (NWC / `nwcClient.js`, `zapController.js`, `zapReceiptValidator.js`). -- [ ] Verify the platform-fee split: correct recipients, correct percentages, rounding, - and that the fee can't silently swallow the whole zap or be bypassed. -- [ ] Confirm zap-receipt validation (kind 9735) is accurate before crediting. +### 3a. Video-modal popover mis-positioning (zap + embed) — BUG +Confirmed via screenshots: the zap/embed popovers OPEN and are fully functional +(comment box, amount, send all render) but appear anchored to the far LEFT of the +content column instead of `bottom-end` under the button (which is in the right- +side action bar). Both share `js/ui/overlay/popoverEngine.js`. +- Leading cause: the engine only portals a panel into the body-level overlay root + `if (!panel.isConnected)`. The zap/embed panels are pre-existing connected + elements INSIDE the modal, so they're NOT portaled out, and `position:fixed` + resolves against an in-modal containing block (something in the modal subtree + has transform/filter/contain) → horizontal offset. +- Ruled out: positioning CSS present in built css; floating-ui local bundle loads; + panel class correct (`.popover__panel`/`.popover-panel` share the rule). +- Fix (do carefully — shared engine drives every popover): either portal connected + in-modal panels out to `#uiOverlay` on open (and restore/clean on close without + losing the element for re-open), OR remove the containing-block ancestor in the + modal subtree. VERIFY non-modal popovers (card "⋯" menu, feed-settings gear, + FeedInfoPopover) still position correctly after the change. +- To pin exactly: inspect `#modalZapDialog` while open — computed `position`, + `left`, `top`, and its DOM parent (is it under `#uiOverlay` at body level or + still nested in the modal?). + +### 3. Zaps system + platform-fee zap split — full plan in docs/zap-audit-plan.md +Audit started 2026-06-23. See the doc for architecture map + findings. Summary: +- [x] **Send-error softened** (`dd1ce113`): recipient LNURL unreachable/CORS now + shows a clear message, not raw "Failed to fetch". +- [ ] **CORS / LNURL proxy decision** (the real reliability fix — static client + can't fetch CORS-less LNURL hosts; many zaps fail). Top of the next session. +- [ ] **Popover positioning (3a)**, **platform-fee split correctness** (incl. the + earlier "fee landed in my own wallet" report + self-zap edge), **receipt + validation (9735)**, **NWC budget/retry UX**, **general clunkiness**. +- [x] Comment box confirmed WORKING (the "message doesn't work" was the popover + mis-position making it hard to use — see 3a). ### 4. View counter accuracy & reliability - [ ] Audit the view-counter system (`viewEvents.js`, `reactionCounter.js`) for @@ -106,11 +133,25 @@ tests → `npm run build` + `npm run test:unit` green → commit + push. double-counting, and resilience to dead relays (now that feed defaults are reserved, ensure view reads/writes use a sane relay set too). -### 5. Card-hide / video-liveness check -- [ ] Audit the card-hide system that gates a card on a video-liveness probe - (`gridHealth.js`, probe concurrency now 4). Confirm: dead URL/magnet hides the - card correctly, a live video is never falsely hidden, and the probe can't - stampede connections (regression risk from the freeze fix). +### 5. Card-hide / video-liveness check — full plan in docs/video-liveness-plan.md +Audited (2026-06-23). Finding: the hide/show **policy is already correct** +(`cardSourceVisibility.js` hides only when neither CDN nor WebTorrent is healthy; +un-hides when WebTorrent flips green). The real gaps: +- [x] **Speed** (`URL_PROBE_TIMEOUT_MS` 8s → 4s): dead hosts yield a fast verdict + so unplayable cards hide quickly. WebTorrent probe stays generous (swarm). +- [x] **Probe accuracy**: confirmed the card probe already uses the video-element + probe (`confirmPlayable:true`, actually loads media) — accurate, not the + opaque HEAD. No change needed. +- [x] **Multi-source**: ingest adapter exposes `sources` (all video imeta urls; + `collectVideoSources`); `urlHealthController.probeUrlList` probes them in + order — healthy as soon as one plays, offline only if all fail. So a dead + primary host no longer hides a video with a working mirror. +- [ ] **Remaining — player fail-over at play time**: the *liveness* now tries all + sources, but actual playback should also fail over to the next source if the + chosen one dies mid-load (playbackService consumes `video.sources`). +- [ ] **Remaining — hide-until-verified for foreign/ingested** (decision): so + unplayable strangers never briefly flash before the probe hides them + (bitvid-native stays show-pending). UX policy change in cardSourceVisibility. ## Open — medium priority @@ -131,8 +172,34 @@ tests → `npm run build` + `npm run test:unit` green → commit + push. surfacing the same control from the Storage settings pane. ### 9. Cap the cold-login relay-REQ storm further -- [ ] Investigate the ~117 REQ/s spike at login. Respect the `capReadRelays` invariant - everywhere fan-out happens; consider staggering subsystem subscriptions at login. +- [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one + kind-30000 REQ *per curator* (~28-32 parallel) at cold start. Collapsed into a + single batched multi-author REQ (`js/adminListBatch.js`), routed through the + subscription manager. This was the dominant `kind 30000` storm. +- [ ] **VERIFY the batching actually reduced it.** A later logged-OUT load + (2026-06-23 00:45) STILL showed `kind 30000=32` — either that build wasn't + loaded, or there's ANOTHER kind-30000 source besides + `loadCommunityBlacklistEntries` (admin editor/whitelist/blacklist lists? a + per-author follow-set fetch?). Re-check on a fresh hard-refresh; if still ~32, + trace the actual emitter. +- [ ] **`from_hex` source not root-caused.** The sanitizer (`733cb593`) defends + + dev-logs `[toolkit] dropped N invalid hex value(s) from filter "<field>"` — + watch that log to find which subscription ships the odd-length hex, fix at source. +- [x] **Relay `from_hex` REQ rejects fixed** (`733cb593`): one odd-length/non-hex + value in `ids/authors/#e/#p/#q` made strict relays reject a whole REQ; now + sanitized at the pool choke point (`normalizeFilterList`). +- [ ] **Remaining: logged-IN cold-start WoT/social-graph fan-out** (~94-104 REQ/s, + bracketed by `[lists-sync-start]`…`[lists-sync-complete]`). Logged-OUT is now + clean. The spike is per-contact hydration over the user's ~25 follows/trusted + contacts: `kind 30000` (follow sets), `30002` (block lists), `30005/30015` + (interest sets), `10050` (DM relay hints), plus `0` (profiles). NOTE: mutes + (`kind 10000`) are ALREADY batched (moderationService.refreshTrustedMuteSubscriptions, + one multi-author filter). Fix = apply the same multi-author batching to the + other per-contact fetches (userBlocks 30002, hashtagPreferences 30005/30015, + follow-set/contacts hydration 30000, DM 10050) and/or DEFER non-feed-critical + ones (DM relays, interest sets) until after first render. Multi-subsystem + + sensitive (trust) → do as a dedicated perf pass, not a rushed change. + Source still to pin: the loop issuing 30000/30002/30005/30015 per contact. ## Open — lower priority / infra @@ -248,12 +315,113 @@ Threat model / cautions (write these into the feature + docs): - [ ] Research: confirm NIP-53 kinds + tag shapes, how zap.stream vs shosho.live populate `streaming`/`recording`, and HLS playback support in the player. -### 17. Short-form / external NIP-71 video note support -- [ ] Likely **NIP-71 Video Events** — the format Amethyst & co. use for "short - form" vertical video: kind **22** (short video) and kind **21** (normal - video). (Confirm current kinds; NIP-71 churned from the older 34235/34236.) -- [ ] bitvid currently publishes its OWN kind 30078 + attaches NIP-71 metadata. - This item is about CONSUMING external NIP-71 notes (render them in the feed / - a short-form view) so bitvid can show video posted by other Nostr apps. -- [ ] Research: confirm whether short-form is purely NIP-71 kind 22, how it maps - onto bitvid's video model (url/magnet/thumbnail), and the vertical/feed UX. +### 17. NIP-71 interop (full plan in `docs/nip71-migration-plan.md`) +Research done; decisions locked. **See the plan doc** — it supersedes the rough +notes below. Summary: +- Opt-in, off by default. Dual-event: keep canonical kind 30078; add an addressable + **34235/34236** mirror (`d`=`videoRootId`, edits in lockstep). 34235/36 are NOT + deprecated — current NIP-71 designates them for editable content. +- WebTorrent rides standard NIP-94 `imeta` fields (`magnet`, `i`); private videos + never mirrored; HTTPS `url` required to mirror. +- [x] Phase 0 (commit 0e2f683b): `buildNip71MirrorEvent` (js/nostr/nip71Mirror.js) + maps a bitvid video → addressable 34235/36 event; magnet/i/ox added to the + imeta builder+parser; mutation-verified tests. Flag still off, no UX. +- [ ] Phase 1 (in progress): + - [x] 1a (e20191d6): `nip71MirrorService.publish` + the ALLOW_NSFW_CONTENT + gate (instance that forbids NSFW won't mirror it outward). + - [x] 1b (58f19095): `remove` teardown — NIP-09 delete (both addressable + kinds) + empty-replace tombstone. + - [x] 1c/1d (783c7df0): My Videos "Share to apps" opt-in toggle wired to the + mirror service via new FEATURE_NIP71_MIRROR flag (on) + + nip71MirrorFlags (per-video opt-in). Ineligible videos show the reason + (private / NSFW-blocked / no-url). LIVE on unstable. + - [x] Auto-sync (89d9a62b): mirror stays in lockstep on edit/delete, + event-driven via nostrService videos:edited/deleted (no nostrService + growth). Edit re-publishes (or unshares if now-private); delete tears + down + clears the flag. + - [x] Hashtags (a1bf7b15, e793b359): the upload modal's existing NIP-71 + hashtag editor already emits `t` tags (feed scoring reads them); the + mirror now carries them too (from t tags / nip71.hashtags). So + publish + feed + mirror hashtags all work. + - [ ] Remaining polish (low value): capture dimensions at upload (short 34236 + + imeta dim — defaults to 34235 now); optional account-level + "auto-share new public videos". + - NOTE: legacy FEATURE_PUBLISH_NIP71 (videoPublisher.js auto-21/22) stays + OFF/dormant — superseded by the opt-in 34235/36 mirror; clean up later. +- [ ] Phase 1.5: NIP-89 handler reg (kind 31990 → "Open in bitvid" elsewhere); + NIP-51 kind 30005 portable playlists. +- [x] Phase 2: inbound ingest of external NIP-71 videos — LIVE on unstable. + - `js/nostr/nip71IngestAdapter.js` (foreign 21/22/34235/34236 → bitvid video; + content-warning→isNsfw; skips bitvid mirrors; surfaces nip71.publishedAt), + `js/services/nip71IngestService.js` (whitelist-scoped subscription, + hydration retry, deferred-until-feed-ready, throttled refresh), + wired in applicationBootstrap. Admin toggle `FEATURE_NIP71_INGEST` + (instance-config) default ON. + - Whitelist model: ingest is scoped to whitelisted authors and reuses the + existing render-time filter (whitelist/NSFW/blacklist/private). When the + admin disables whitelist mode, ingest opens to all authors (capped). + - KEY FIX: the feed's resolve-posted-at stage was firing a per-video + hydrateVideoHistory() network fetch for each ingested video (no kind-30078 + history exists) → relay storm + feed hang. Fixed by surfacing + nip71.publishedAt so the stage short-circuits. + - Verified live: Goblinbox (Nostube) content renders; liveness check works. + +### 17c. NIP-71 on-boarding + cross-ecosystem dedup (IMPORTANT — full plan in docs/nip71-onboarding-plan.md) +Make NIP-71 interop two-way and guarantee a video is never shown twice. +**See `docs/nip71-onboarding-plan.md` for the full dev plan.** Locked decisions: +creator-initiated self-import (model A — can't forge others' notes), dedup on +explicit import-link + content hash/infohash (no fuzzy url/title), import grants +CDN url + WebTorrent (bitvid extras come free), one-shot import + re-run button. +- [x] **Phase 1: cross-ecosystem dedup** (`js/utils/videoDeduper.js`): + `collapseCrossEcosystem` keyed per-author on `fileSha256/ox/infoHash` + an + `eid` namespace linking on-board provenance (`importedFrom`); prefers the + bitvid (kind-30078) version. Composed into `dedupeVideos` (root-dedup THEN + cross-ecosystem) and routed through the shared chokepoint + `app.dedupeVideosByRoot` → covers the feed stage + channel + search at once. + Pure + mutation-verified. (Hash-less dual-posts still can't dedup — documented + limitation; covered once on-boarded videos carry the provenance link.) +- [ ] **Phase 2: creator-initiated import** — discover the logged-in creator's own + NIP-71 videos lacking a bitvid version; offer FULL import (re-host to THEIR + storage + optional WebTorrent) or REFERENCE import (keep external URL, no + re-host); publish a kind-30078 signed by them with an `["imported-from", <orig>]` + provenance tag. Same-pubkey-only guard; ALLOW_NSFW gate; reuses ingest adapter + + storage/upload + videoPublisher. +- [ ] **Phase 2b: externally-managed storage flag** — imports NOT produced by our + storage system (reference imports / external URLs) must be explicitly flagged + (`externalStorage: true`, durable — not just the `isUrlUnderBase` heuristic) so + storage tooling never treats them as bitvid-owned: My Videos shows an external + badge (never "missing from bucket"), orphan reconciliation excludes them, the + delete "hosted file left behind" warning is suppressed, liveness stays + unverifiable. Full (re-hosted) imports set the flag false. + +### 17d. Unified video-grid loading (streaming + optimistic) — follow-ups +Goal: every grid loads with the same fast UX. Status: cross-ecosystem dedup +(17c Phase 1) is live at the shared chokepoint; main feed already streams + +has a persisted cache; channel/search now render optimistically from +`allEvents` (incl. ingested NIP-71). Remaining: +- [ ] **Streaming channel/search fetch.** `loadUserVideos` does a one-shot + `pMapSettled(relays, pool.list)` and renders only after ALL relays settle, + so a slow relay holds up the grid (~2-3s). Switch to the subscription + manager's streaming + render incrementally (first-relay-wins), like the main + feed's buffered subscription. Extract the fetch into `channelProfileVideos.js` + (channelProfile.js is at its size cap). +- [x] **Per-channel persisted cache** (`channelProfileVideos.js`, + `bitvid:channel-videos:v1`): cold hard-refresh of a profile now paints the + last-seen videos from localStorage before relays connect, then the live fetch + replaces them. Bounded (60 videos/channel, 20 channels LRU), strips raw tags. + Tests + mutation-verified. +- [ ] **My Videos: include NIP-71** in its `allEvents` read so a creator's own + cross-posted videos appear in management too (reuse the cross-ecosystem + dedupe so the bitvid version wins). +- [ ] **Consolidate** the bespoke loaders (channel/search/My Videos) behind one + shared streaming grid source so future grids inherit streaming + optimistic + + dedupe for free. + +### 17b. Channel Profile wall shows only bitvid videos, not NIP-71 (LAUNCH-BLOCKER) +- [x] **Fixed** (`js/channelProfileVideos.js`): the channel grid now fetches the + author's NIP-71 videos (kinds 21/22/34235/34236) alongside kind-30078 via + `buildChannelVideoFilters`, and `convertChannelEvent` routes NIP-71 events + through `buildVideoFromNip71Event` (skips bitvid mirrors). Existing + dedupe-by-root + canAccess in `buildRenderableChannelVideos` handle + precedence/gating. Logic extracted to a helper so channelProfile.js stayed + under its size cap. Tests + mutation-verified.