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"
>
+
"
+git diff --quiet origin/unstable HEAD && echo "beta tree == unstable" # expect 0
+git push origin beta
+
+# main: fast-forward to unstable
+git checkout main && git reset --hard origin/main
+git merge --ff-only origin/unstable
+git push origin main
+```
+
+### Build/deploy each project (on-demand)
+```bash
+# beta
+vercel link --yes --project bitvid-beta && vercel deploy --prod --yes
+# production
+vercel link --yes --project bitvid && vercel deploy --prod --yes # aliases to bitvid.network
+# restore the day-to-day link
+vercel link --yes --project bitvid-unstable
+```
+
+### Verify
+The footer version marker (`v: <8hex> • `) is content-hash based; all three
+projects build from the same source in the same Vercel env, so the markers should
+**match each other** after a promotion. Verify per domain and confirm a known new
+module is served, e.g.:
+
+```bash
+for host in unstable.bitvid.network bitvid-beta.vercel.app bitvid.network; do
+ echo -n "$host : "; curl -s "https://$host/index.html?cb=$(date +%s)" \
+ | grep -oE 'v: [0-9a-f]{8} • [0-9-]+' | head -1
+done
+```
+
+> Caveat: promoting a large batch straight to `main` skips the `beta` soak. Do it
+> only deliberately; otherwise let changes soak on `beta` first. Rollback = redeploy
+> the previous `bitvid` production deployment (`vercel rollback` or redeploy by URL).
diff --git a/docs/nip71-migration-plan.md b/docs/nip71-migration-plan.md
new file mode 100644
index 00000000..3b3d7d0e
--- /dev/null
+++ b/docs/nip71-migration-plan.md
@@ -0,0 +1,295 @@
+# NIP-71 Interop Plan for bitvid
+
+Status: **planning / spec** (no code yet). Last updated 2026-06-22.
+
+Goal: let a publisher **opt in** to making a bitvid video fully NIP-71 compliant so
+it (a) shows up on other Nostr video clients (Nostube, Amethyst, Flare, zap.stream
+recordings, …) and (b) still carries bitvid's extra data (WebTorrent, flags) for a
+richer experience inside bitvid. Eventually bitvid also **ingests** other clients'
+NIP-71 videos. Decisions below were made with the maintainer; see "Decisions".
+
+---
+
+## Decisions (locked)
+
+1. **Kind: addressable `34235` (normal) / `34236` (short).**
+2. **Opt-in, off by default** — per-video toggle; private videos never mirrored.
+3. **Scope: outbound first, inbound later** (Phase 1 vs Phase 2).
+4. **Dual-event model (decided 2026-06-22), NOT single-canonical.** Considered
+ collapsing to one note type by making bitvid's canonical video event itself
+ kind 34235/36. Verdict: keep kind 30078 canonical + an additive mirror.
+ - *Why a single note is appealing:* one event, no drift, fully discoverable.
+ - *Why we're not doing it (yet):* (a) **private videos can't be public NIP-71**
+ (no encryption) so they'd stay 30078 regardless — "one note for everything"
+ is impossible, only "one note for public"; (b) **kind 30078 is the addressing
+ root** — `30078:pubkey:d` is referenced by watch-history pointers, comments,
+ reactions, view counts, playlists, naddr deep-links, and moderation, plus
+ hardcoded video subscription filters (`nostrService.VIDEO_KIND`,
+ `videoPointer.DEFAULT_VIDEO_KIND`, `kinds:[30078]` across client/channel/
+ search). Switching the canonical kind ripples through all of them and needs a
+ forward-only dual-read migration. Not worth the risk for the interop win.
+ - *Accepted trade-off:* two events per shared video; the mirror can drift from
+ the canonical, so the lifecycle must keep them in sync (auto re-publish on
+ edit / remove on delete) — see Phase 1 polish.
+ - The Phase 0/1 builder+parser are reusable if we ever revisit single-canonical
+ (they'd become bitvid's native 34235 read/write layer).
+
+### Why 34235/34236 (research-validated)
+
+The earlier assumption that 34235/36 were "deprecated legacy" was **wrong**. The
+current NIP-71 (master) defines **all four** kinds and explicitly designates the
+addressable pair for editable content:
+
+> "For content that may need updates after publication (such as correcting
+> metadata, descriptions, or handling URL migrations), addressable versions are
+> available: Kind `34235` … Kind `34236` … include a `d` tag as a unique
+> identifier and can be updated while maintaining the same addressable reference."
+
+That is **exactly** bitvid's model (replaceable kind 30078, edit-in-place via
+`d` = `videoRootId`). So 34235/36 is not a compromise — it's the spec-recommended
+kind for what bitvid does, and it's what Nostube/Goblinbox consume today.
+
+> Hedge: keep the builder **kind-parameterized** so we can also emit / switch to
+> `21`/`22` (regular) later without a rewrite if the ecosystem leans that way.
+
+---
+
+## Architecture: dual-event, additive
+
+- **Canonical stays kind 30078** (NIP-78 app-data), unchanged. Source of truth for
+ WebTorrent, private videos, moderation, view counts — none of which NIP-71 models.
+- **Mirror = kind 34235/34236**, addressable with **`d` = `videoRootId`** (same
+ d-tag as the 30078), so the mirror edits/deletes in **lockstep** with the canonical.
+- Correlation is automatic via `(pubkey, videoRootId)`. We also set:
+ - `["origin", "bitvid", "", "https://bitvid.network/?v="]`
+ so other clients can attribute + deep-link back ("watch on bitvid").
+ - Optionally `["a", "30078::"]` back-pointer (harmless elsewhere).
+- **bitvid never double-renders its own mirror.** Inside bitvid the 34235/36 is
+ merged onto its 30078 by `videoRootId` (`nip71Cache` already keys on it) — it is
+ metadata enrichment, never a second feed card. This dedup applies from Phase 1
+ (our own mirror), not just Phase 2 (foreign videos).
+
+---
+
+## Field mapping: 30078 JSON → 34235/36 tags
+
+Sourced against NIP-71 + NIP-92 (`imeta`) + NIP-94 (file metadata fields).
+
+| bitvid (30078 content) | NIP-71 / imeta | notes |
+|---|---|---|
+| — | `["d", videoRootId]` | required for addressable; lockstep with 30078 |
+| `title` | `["title", …]` | required |
+| `description` | `.content` (full description) | |
+| short text | `["alt", …]` | accessibility text — title / first line, NOT a dump of the full description |
+| first publish time | `["published_at", …]` | read from the ORIGINAL 30078 (root `created_at`/`published_at`); **stable across edits** so foreign clients don't reorder |
+| `url` (HTTPS) | `imeta url <…>` | primary playable source for foreign clients |
+| derived MIME | `imeta m <…>` | not stored today → derive from extension, default `video/mp4` |
+| `thumbnail` | `imeta image <…>` | |
+| `duration` (upload `#input-duration`) | `imeta duration ` | already collected at upload — wire it through |
+| dimensions (probe at upload) | `imeta dim x` **+ 34235-vs-34236 selection** | not captured today → add a `loadedmetadata` probe |
+| `fileSha256` | `imeta x <…>` | |
+| `originalFileSha256` | `imeta ox <…>` | |
+| `magnet` | `imeta magnet ` | **standard NIP-94 field** — torrent-aware clients can use it |
+| `infoHash` | `imeta i ` | **standard NIP-94 field** |
+| `ws` / `xs` | encoded into the `magnet` URI (`&ws=`/`&xs=`) | also kept on 30078 canonical |
+| `isNsfw` | `["content-warning", ""]` | standard |
+| hashtags | `["t", ]` (repeated) | written to **both** 30078 and the mirror; see "Hashtags & the feed" |
+| `isForKids`, `mode`, etc. | bitvid-namespaced extra tags | ignored by other clients |
+| — | `["client", "bitvid"]` | |
+
+**Two hard rules that fall out of this:**
+
+1. **Private videos are never mirrored.** NIP-71 has no encryption; mirroring would
+ leak them. The toggle is disabled (with explanation) for private videos.
+2. **A hosted HTTPS `url` is required to mirror.** Foreign clients can't play a
+ magnet-only video; bitvid's magnet still rides along (`magnet`/`i`) for
+ torrent-aware clients, but `url` must be present or the toggle is disabled.
+3. **The `url` must be publicly reachable** (CORS + range requests) so other
+ clients can actually play it. Gate the toggle on bitvid's existing public-access
+ verification (`verifyPublicAccess`) — don't advertise a video elsewhere that
+ only bitvid can fetch.
+
+**Timestamp hygiene:** `created_at` = actual signing time; `published_at` = display
+time. (Avoids the future-`created_at` weirdness seen in some published events.)
+
+---
+
+## Hashtags & the feed (no algo changes)
+
+The explore/kids feed scoring **already** consumes NIP-71 hashtags:
+`js/feedEngine/exploreScoring.js` builds each video's tag vector from **both** the
+raw `["t", …]` tags on the event **and** `video.nip71.hashtags`, then matches it
+against the user's interests/disinterests vector. `nip71.js` already parses `t`
+tags into `metadata.hashtags` and merges NIP-71 metadata onto videos by
+`videoRootId` (`nip71Cache`). So a native 30078 video, a 34235/36 mirror, and an
+ingested foreign NIP-71 video all slot into the **same** hashtag-driven scoring —
+**the feed algorithm needs zero changes.**
+
+The only gap is **capture**: bitvid's current 30078 content schema has no per-video
+hashtags field (videos only get the topic tag `["t","video"]`). So the work is to
+add a hashtags input and emit `["t", tag]` on **both** events. This is part of
+Phase 1.
+
+## Converting existing videos (the edit/upgrade flow)
+
+Opting an existing video into NIP-71 is just an **edit**. When the publisher toggles
+the mirror on (or edits an already-mirrored video), bitvid maps every available
+30078 field into its NIP-71 home per the table above — title→`title`,
+description→`.content`/`alt`, url→`imeta url`, thumbnail→`imeta image`,
+magnet/infoHash→`imeta magnet`/`i`, nsfw→`content-warning`, hashtags→`t`, etc. —
+and writes the 34235/36 mirror with `d` = `videoRootId` so it stays addressable and
+lockstep-editable.
+
+Practical notes for conversion:
+- Existing videos have **no user hashtags yet**, so the edit form surfaces the new
+ hashtags input; tags the publisher adds are written to **both** the 30078 and the
+ mirror (keeping the feed unified, native + mirrored).
+- Conversion is non-destructive: the 30078 stays canonical; the mirror is additive.
+- Re-editing later updates both in lockstep (same `d`-tag); toggling off NIP-09s the
+ mirror only.
+
+---
+
+## Discovery & relays (the part that decides if anyone sees it)
+
+A format-perfect mirror is **invisible** if it isn't on the relays other clients
+read. This is the #1 thing that makes or breaks interop, and it's a *relay* problem,
+not a format problem.
+
+- bitvid already has NIP-65 (`relayManager.publishRelayList`, kind 10002, + outbox
+ reads), and `publishVideo` targets `this.writeRelays`. The mirror must publish to
+ the user's **NIP-65 write relays (outbox model)** — that's where outbox-aware
+ clients (Amethyst, etc.) look for that author's content.
+- **Ensure the user has a published, accurate NIP-65 list** when they first enable
+ the mirror (publish kind 10002 if missing). Without it, outbox clients can't find
+ the video regardless of correctness.
+- Consider also fanning the mirror out to a small set of **well-known general/video
+ relays** so non-outbox clients (and search) can discover it. Keep this list short
+ and configurable; respect the relay-fan-out cap lessons (don't storm).
+- Relay reads for any future ingest go through the subscription manager
+ (lint:pool-access), never `pool.list` directly.
+
+---
+
+## Phases
+
+### Phase 0 — Retarget builders (no UX; flag stays off)
+- Point existing `buildNip71VideoEvent` at **34235/36 + `d`=`videoRootId`** (it
+ currently targets 21/22). Implement the field mapping incl. `magnet`/`i`/`ox`/
+ `duration` (already collected at upload).
+- Normal-vs-short selection: default 34235; choose 34236 when known-portrait
+ (from captured dimensions; otherwise a manual "short" toggle).
+- Cheat-resistant tests: bitvid video → spec-valid 34235 event → round-trips back
+ via `convertEventToVideo`; magnet/infohash present; private refused; url-less refused.
+- **Interop golden test**: assert the produced event matches the spec shape
+ (`d`/`title`/`published_at`/`imeta url`+`image`) so a foreign parser (Nostube-style)
+ can read it — guards against silent shape drift.
+
+### Phase 1 — Outbound publish/edit/delete (the headline feature)
+- Edit form toggle: **"Also publish to other Nostr video apps (NIP-71)"** — off by
+ default, gated to **public + HTTPS-url + public-access-verified** videos, and
+ **not offered for `isNsfw` videos when `ALLOW_NSFW_CONTENT=false`** (see
+ "Moderation, web-of-trust, mute/block & NSFW").
+- Surface the same control in the **My Videos tab** (per-video mirror status +
+ toggle; it already lists owned videos with action buttons). Optional bulk
+ "make discoverable" there later.
+- **Capture dimensions at upload** (`loadedmetadata` probe) for 34235-vs-34236
+ selection + `imeta dim`; wire the existing `duration` through.
+- **Add a hashtags input**; write `["t", tag]` on **both** the 30078 and the mirror
+ (the feed already scores these — no algo change).
+- **Field conversion on edit/upgrade**: map all available 30078 fields into their
+ NIP-71 homes per the mapping table (see "Converting existing videos").
+- Publish: 30078 (as today) **+** the 34235/36 mirror to the user's **outbox
+ (NIP-65 write) relays**; publish a kind-10002 list first if the user has none
+ (see "Discovery & relays"). Reuse the publish-outcome toast.
+- Lifecycle parity: **edit** updates both (same d-tag); **delete** publishes a
+ NIP-09 (`kind 5`) referencing the mirror's `["a","34235::"]` (+ `k`),
+ AND empties the addressable event (empty-replace, the watch-history/encrypted-sync
+ lesson) so clients that ignore NIP-09 still see it cleared; **toggle-off** does the
+ same for the mirror only.
+- Mutation-verified lifecycle tests, then flip `FEATURE_PUBLISH_NIP71` on.
+
+### Phase 1.5 — Discoverability glue (cheap, high interop value)
+- **NIP-89 handler registration** (kind `31990`): publish once that bitvid handles
+ video kinds `21/22/34235/34236` with a `web` URL template. Result: other clients
+ (Amethyst, etc.) can show **"Open in bitvid"** when they encounter a video event.
+- **NIP-51 video curation sets** (kind `30005`): publish/read bitvid playlists as
+ portable video sets so playlists interop across clients. (Sets reference videos
+ by `e`/`a` tags.)
+
+### Phase 2 — Inbound ingest (separate, bigger)
+- Feed source querying `34235/36` (and `21/22`) from relays; map via
+ `convertEventToVideo`.
+- Dedup vs bitvid-native (by `a`-address / `x` hash / `url`), moderation + trust
+ gating (reuse blacklist / NIP-51 mute), and a **distinct discovery surface** so
+ foreign videos read as "from the wider Nostrverse," not bitvid-native.
+- Honor `origin` for attribution; respect the no-URL-allowlist rule but apply
+ existing moderation.
+- (Cross-client comments/zaps via NIP-22 `kind:1111` addressed to the 34235 event
+ is a further follow-on — bitvid comments currently target 30078.)
+
+---
+
+## Moderation, web-of-trust, mute/block & NSFW (audited 2026-06-22)
+
+The existing controls key on **author pubkey** and **event id**, so they apply to
+NIP-71 videos automatically — *as long as ingested videos flow through the same feed
+pipeline*. One real gap (inbound NSFW) + one config-respect (outbound).
+
+**Outbound (Phase 1):**
+- [ ] Respect the site `ALLOW_NSFW_CONTENT` config (`config/instance-config.js`,
+ default `false`): when false, **do not offer/allow the mirror for `isNsfw`
+ videos** — an instance that won't surface NSFW shouldn't publish it outward.
+ The pure builder stays config-agnostic and always sets `content-warning` when
+ NSFW; gate at the call site.
+- Mute/block/WoT/admin-blacklist don't newly apply outbound — it's the user's own
+ content (a blacklisted user can't publish anyway).
+
+**Inbound (Phase 2) — the real moderation work:**
+- [ ] **`content-warning` → `isNsfw`**: `mergeNip71MetadataIntoVideo` does NOT
+ currently map a NIP-71 `content-warning` to `video.isNsfw`. The site NSFW
+ filter keys on `isNsfw`, so foreign NSFW would bypass it. Map it in the ingest
+ path so `ALLOW_NSFW_CONTENT=false` filters foreign NSFW too.
+- [ ] **Same pipeline, no bypass**: route every ingested video through the existing
+ stages — `blacklist-filter` (`shouldIncludeVideo`: admin/event-id blacklist +
+ blocked authors), `moderation` (admin whitelist/blacklist + trusted-mute /
+ trusted-report thresholds = web-of-trust), watch-history suppression,
+ kids-audience, NSFW. These key on pubkey/id, so mute lists (NIP-51 kind 10000),
+ blocks, admin lists, and WoT thresholds catch foreign authors automatically.
+- [ ] **Trust gate for strangers**: don't surface unknown/untrusted foreign authors
+ by default — apply the same trust-seed / web-of-trust gating native videos get
+ (consider requiring author ∈ WoT, or a stricter default threshold for ingest).
+- **Kids feed is already safe**: `kidsAudienceFilterStage` is allowlist-based
+ (`isForKids !== true` ⇒ excluded) and also drops `isNsfw` + disallowed
+ content-warnings, so foreign videos (no `isForKids`) never enter the kids feed.
+
+---
+
+## Enrichment backlog (nice-to-have, not v1)
+`bitrate`, `waveform`, multiple resolution `imeta` variants + multi-audio
+(`ov`/language) tracks, `text-track` (WebVTT captions/subtitles/chapters),
+`segment` (chapters) — all need more captured/transcoded at upload time. v1 ships
+spec-valid imeta with `url`/`m`/`image`/`dim`/`duration`/`x` + `magnet`/`i`, and
+grows from there. (`dim`/`duration` are pulled into v1 — see Phase 1.)
+
+---
+
+## Risks / watch-items
+- **Editing addressable events across foreign clients**: some cache aggressively;
+ edits may lag elsewhere. Acceptable; document it.
+- **Magnet-only catalogs**: those videos simply won't appear on non-torrent clients
+ (by design — they still work in bitvid).
+- **Kind drift**: if the ecosystem consolidates on 21/22, add/dual-emit later
+ (builder stays kind-parameterized).
+- **Ingest safety (Phase 2)**: arbitrary foreign media + metadata → keep moderation,
+ no autoplay of untrusted sources, sanitize before render.
+
+---
+
+## Sources
+- NIP-71 Video Events (current): https://github.com/nostr-protocol/nips/blob/master/71.md (vendored at `docs/nips/71.md`)
+- NIP-92 inline metadata (`imeta`): https://github.com/nostr-protocol/nips/blob/master/92.md
+- NIP-94 file metadata (`magnet`, `i`, `x`, `ox`, `fallback`, …): https://github.com/nostr-protocol/nips/blob/master/94.md
+- NIP-89 recommended application handlers (`31989`/`31990`): https://github.com/nostr-protocol/nips/blob/master/89.md
+- NIP-51 lists/sets incl. kind `30005` video curation: https://github.com/nostr-protocol/nips/blob/master/51.md
+- Existing bitvid code: `js/nostr/nip71.js` (builders/parser), `js/nostrEventSchemas.js` (`NIP71_VIDEO`/`NIP71_SHORT_VIDEO`), `js/constants.js` (`FEATURE_PUBLISH_NIP71`).
diff --git a/docs/nip71-onboarding-plan.md b/docs/nip71-onboarding-plan.md
new file mode 100644
index 00000000..6098f300
--- /dev/null
+++ b/docs/nip71-onboarding-plan.md
@@ -0,0 +1,160 @@
+# NIP-71 On-Boarding + Cross-Ecosystem Dedup — Dev Plan
+
+Status: **planned** (not started). Owner: TBD. Branch: `unstable`.
+Companion to `docs/nip71-migration-plan.md` (off-boarding / mirror / ingest, all shipped).
+
+## Goal
+
+Make NIP-71 interop a **two-way street**:
+
+1. **Off-boarding (DONE):** a bitvid video (kind 30078) is mirrored *out* to NIP-71
+ (addressable 34235/36) so other apps can discover it.
+2. **On-boarding (THIS PLAN):** a creator's NIP-71 video published via another app
+ can be pulled *into* bitvid so it gains bitvid's advantages — CDN-hosted URL,
+ a WebTorrent source, and (optionally) bitvid extras.
+
+And the invariant that makes both safe to coexist:
+
+3. **One video, never two.** When the same video exists in both ecosystems, always
+ prefer the **bitvid** (kind-30078) version, show **exactly one**, in **every**
+ grid/list/scenario. Never both.
+
+## The hard constraint (why on-boarding is creator-initiated)
+
+Off-boarding works because the bitvid creator owns the video and signs BOTH events
+with their own key. On-boarding is **not symmetric**: a foreign NIP-71 video is
+signed by the foreign creator's key, and bitvid cannot forge a kind-30078 note
+authored by someone else (no private key). Therefore:
+
+- **On-boarding = creator-initiated self-import only.** A creator who published
+ NIP-71 videos elsewhere logs into bitvid **with the same npub**, and bitvid
+ offers to import them: re-host the file to **their own** connected storage (CDN),
+ optionally seed WebTorrent, and publish a bitvid kind-30078 version **signed by
+ their key**. No auto-rehosting of other people's files (consent / cost /
+ liability). This mirrors off-boarding exactly: both directions author-owned and
+ consensual.
+
+## Locked decisions (from design discussion)
+
+- On-boarding model: **A — creator-initiated self-import.**
+- Dedup identity: **explicit import-link + content hash** (`fileSha256`/`ox`) +
+ infohash (`i`/magnet btih). NO fuzzy url/title matching (avoids false merges).
+ Note: hash-less foreign videos (e.g. url+image only) won't dedup unless they
+ were on-boarded (which adds an explicit link) — acceptable.
+- Advantages on import: **CDN url + WebTorrent source** (baseline). bitvid extras
+ (comments, view counts, edit-in-place, private toggle) come free once it's a
+ real 30078 note; no extra work.
+- Cadence: **one-shot import with a re-run button** (not continuous lockstep).
+ Re-running re-imports anything new.
+
+## Phasing — dedup FIRST (it's needed regardless of on-boarding)
+
+### Phase 1 — Cross-ecosystem dedup ("prefer bitvid, show one, everywhere")
+
+Why first: even today, a creator who independently posts to bitvid AND Nostube can
+produce two cards. And the moment on-boarding exists, the original + imported
+versions both exist on relays. Dedup must land before/with on-boarding.
+
+- **Identity key:** `author + (importLink ‖ fileSha256 ‖ ox ‖ infoHash)`.
+ - `importLink`: pointer an on-boarded 30078 carries to the original NIP-71
+ event/address (added in Phase 2). Most reliable.
+ - Hashes/infohash already extracted by `convertEventToVideo` (30078) and
+ `buildVideoFromNip71Event` (foreign) — confirm both populate `fileSha256` /
+ `originalFileSha256` / `infoHash`.
+- **Preference:** within an identity group, keep the bitvid version
+ (`source !== "nip71-ingest"` / kind 30078); else keep the NIP-71 one.
+- **Placement (one chokepoint → all grids):** the feed AND channel profile already
+ call `app.dedupeVideosByRoot(...)`. Add a pure `collapseCrossEcosystem(videos)`
+ pass and either chain it after root-dedup inside that shared helper, or wrap
+ both into one `dedupeVideos(videos)` used everywhere (feed engine
+ `createDedupeByRootStage`, `buildRenderableChannelVideos`, My Videos, search,
+ playlists, watch history). Audit every list builder for the shared call.
+- **Bitvid's own outbound mirrors** are already skipped at ingest
+ (`client=bitvid` + `a` back-pointer) — keep that; this pass handles the rest.
+- **Tests (cheat-resistant, pure):** same-hash bitvid+foreign → only bitvid kept;
+ foreign-only (no bitvid) → foreign kept; distinct videos not merged; hash-less
+ pair NOT merged (documented limitation); import-link match → original hidden.
+ Mutation-verify the prefer-bitvid selection and the identity grouping.
+
+### Phase 2 — Creator-initiated on-board import
+
+- **Discovery:** on login, find the logged-in creator's own NIP-71 videos (reuse
+ the ingest subscription/adapter, scoped to `authors:[viewerPubkey]`) that have
+ **no** corresponding bitvid 30078 yet (via the Phase-1 identity key).
+- **UI:** a "Import your videos from other apps" surface (My Videos pane or a
+ one-time prompt): list importable items, let the creator pick, show what import
+ does. Requires their **storage connected** (re-host target) and signer.
+- **Import action (per video), reusing existing plumbing:**
+ 1. Fetch the source file from the foreign `url` (or resolve via magnet).
+ 2. Re-host to the creator's connected storage (reuse `storageService` /
+ `r2Service` upload path used by normal uploads) → bitvid CDN url.
+ 3. Optionally create a `.torrent` + WebTorrent magnet (reuse upload's torrent
+ path) so the video gets a P2P source.
+ 4. Build + publish a kind-30078 note **signed by the creator** (reuse
+ `videoPublisher` / `buildVideoPostEvent`), carrying: title, description,
+ hashtags, dims/duration (from the foreign imeta via the adapter), the new
+ CDN url + magnet, AND an explicit `["imported-from", ""]`
+ provenance tag for dedup + attribution.
+ 5. (Optional, symmetric) since it's now a normal bitvid video, the existing
+ outbound mirror/auto-share can keep the NIP-71 side in lockstep going forward.
+- **Import modes (decision):** offer both
+ - **Full import (re-host):** file copied to the creator's storage → bitvid-managed
+ (CDN url under their bucket), normal storage tooling applies.
+ - **Reference import (no re-host):** publish a 30078 that keeps the **external**
+ URL (lighter, no storage cost). The video is now a bitvid note but its file is
+ **externally managed** — see storage-provenance below.
+
+- **Result:** the bitvid 30078 is now canonical; Phase-1 dedup hides the original
+ foreign event everywhere; the creator gets bitvid features + (full import) CDN +
+ WebTorrent, or (reference import) discovery without re-hosting.
+
+### Storage provenance — flag externally-managed imports
+
+Any imported (or otherwise bitvid-converted) video whose file was **not** produced
+by our storage system must be explicitly flagged as **externally managed** so the
+storage tooling never treats it as a bitvid-owned object.
+
+- **Today:** `isUrlUnderBase(url, publicBaseUrl)` (myVideosHealth.js) already
+ classifies bucket vs external ("Hosted URL" vs "External URL"), and
+ `reconcileStorage` only matches files under the base. So a reference-imported
+ external URL is *mostly* handled by the heuristic.
+- **Add (durable + explicit):** stamp the on-boarded note as externally managed
+ rather than relying solely on the URL-vs-base heuristic (which breaks if the
+ creator changes their storage base, or an external host coincidentally matches).
+ Carry it on the video object (e.g. `externalStorage: true`, derived from the
+ `imported-from`/reference-import path) and, where appropriate, on the event.
+- **All storage surfaces must honor it:**
+ - **My Videos health:** show an "External URL / externally managed" badge; never
+ flag as missing-from-bucket.
+ - **Orphan reconciliation:** exclude — never list as an orphan, never offer
+ bucket delete for a file we don't own.
+ - **Delete flow:** suppress the "a hosted file is left behind" storage-cleanup
+ warning for externally-managed videos (there's nothing in our bucket to clean).
+ - **Liveness:** external URL stays "unverifiable" (existing behavior).
+- **Full import** sets `externalStorage: false` (it IS in the creator's bucket) and
+ behaves like any native upload.
+- **Guards:**
+ - Only the **same-pubkey** creator can import their own videos (signer == author).
+ - Never import NSFW outward against `ALLOW_NSFW_CONTENT` (mirror the existing
+ gate); carry `content-warning → isNsfw`.
+ - Re-host uses the creator's storage only; surface cost/ownership in the copy.
+ - One-shot; a "re-scan / import new" button re-runs discovery.
+
+## Non-goals / future
+
+- No instance-level auto-rehosting of arbitrary creators' files (model B) — consent/
+ cost/liability. Revisit only with explicit per-instance policy + creator opt-in.
+- No fuzzy (url/title) dedup — revisit only if hash-less dual-posts become a real
+ problem.
+- Continuous lockstep on the inbound side (re-import on the creator editing the
+ original elsewhere) — deferred; one-shot + re-run is enough for launch.
+
+## Reuse map (existing code to lean on)
+
+- `js/nostr/nip71IngestAdapter.js` — foreign event → video (hashes, dims, imeta).
+- `js/nostr/nip71Mirror.js` — outbound mirror (keeps NIP-71 side in lockstep after import).
+- `js/services/nip71IngestService.js` — author-scoped subscription pattern for discovery.
+- `js/channelProfileVideos.js`, feed `createDedupeByRootStage`, `app.dedupeVideosByRoot`
+ — the dedup chokepoints to extend.
+- `storageService` / `r2Service` / upload + torrent paths — re-host + seed.
+- `videoPublisher` / `buildVideoPostEvent` — publish the signed 30078.
diff --git a/docs/nips/71.md b/docs/nips/71.md
index 303ec5a3..e55e470d 100644
--- a/docs/nips/71.md
+++ b/docs/nips/71.md
@@ -20,16 +20,34 @@ Nothing except cavaliership and common sense prevents a _short_ video from being
The format uses a _regular event_ kind `21` for _normal_ videos and `22` for _short_ videos.
+## Addressable Video Events
+
+For content that may need updates after publication (such as correcting metadata, descriptions, or handling URL migrations), addressable versions are available:
+
+- Kind `34235` for _addressable normal videos_
+- Kind `34236` for _addressable short videos_
+
+These addressable events follow the same format as their regular counterparts but include a `d` tag as a unique identifier and can be updated while maintaining the same addressable reference. This is particularly useful for:
+
+- Metadata corrections (descriptions, titles, tags) without republishing
+- Preservation of imported content IDs from legacy platforms
+- URL migration when hosting changes
+- Platform migration tracking
+
The `.content` of these events is a summary or description on the video content.
The primary source of video information is the `imeta` tags which is defined in [NIP-92](92.md)
-Each `imeta` tag can be used to specify a variant of the video by the `dim` & `m` properties.
+Each `imeta` tag can be used to specify a variant of the video by the `dim` & `m` properties, as well as multiple audio tracks (languages).
-This NIP defines the following additional `imeta` properties aside form those listen in [NIP-92](92.md) & [NIP-94](94.md):
+Clients should check if audio tracks are available and prefer them over in-video audio
+to allow smooth resolution switching without interrupting audio. The `ov` (original version) flags the original language track.
+
+This NIP defines the following additional `imeta` properties aside from those listed in [NIP-92](92.md) & [NIP-94](94.md):
* `duration` (recommended) the duration of the video/audio in seconds (floating point number)
* `bitrate` (recommended) the average bitrate of the video/audio in bits/sec
+* `waveform` (optional, audio only) amplitude values over time, space separated full integers, less than 100 values should be enough to render a nice visual
Example:
```json
@@ -72,6 +90,18 @@ Example:
"service nip96",
"duration 29.21"
],
+ ["imeta",
+ "url https://myaudio.com/audio/en/12345.mp3",
+ "x b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93",
+ "m audio/mp3",
+ "waveform 0 7 35 8 100 100 49 8 4 16 8 10 7 2 20 10 100 100 100 100 100 100 15 100 100 100 25 60 5 4 3 1 0 100 100 15 100 29 88 0 33 11 39 100 100 19 4 100 42 35 5 0 1 5 0 0 11 38 100 94 17 11 44 58 5 100 100 100 55 14 72 100 100 57 6 1 14 2 16 100 100 40 16 100 100 6 32 14 13 41 36 16 14 6 3 0 1 2 1 6 0",
+ "l en ISO-639-1 ov",
+ "fallback https://myotherserver.com/audio/en/12345.mp3",
+ "fallback https://andanotherserver.com/audio/en/12345.mp3",
+ "service nip96",
+ "bitrate 320000",
+ "duration 29.24"
+ ],
]
```
@@ -81,6 +111,9 @@ The `image` tag contains a preview image (at the same resolution). Multiple `ima
Additionally `service nip96` may be included to allow clients to search the authors NIP-96 server list to find the file using the hash.
+### Required tags for addressable events:
+* `d` - Unique identifier for this video (user-chosen string, required for kinds 34235, 34236)
+
### Other tags:
* `title` (required) title of the video
* `published_at`, for the timestamp in unix seconds (stringified) of the first time the video was published
@@ -92,7 +125,10 @@ Additionally `service nip96` may be included to allow clients to search the auth
* `p` (optional, repeated) 32-bytes hex pubkey of a participant in the video, optional recommended relay URL
* `r` (optional, repeated) references / links to web pages
-```jsonc
+### Optional tags for imported content:
+* `origin` - Track original platform and ID: `["origin", "", "", "", ""]`
+
+```yaml
{
"id": "<32-bytes lowercase hex-encoded SHA-256 of the the serialized event data>",
"pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>",
@@ -135,3 +171,63 @@ Additionally `service nip96` may be included to allow clients to search the auth
]
}
```
+
+## Addressable Event Example
+
+```yaml
+{
+ "id": <32-bytes lowercase hex-encoded SHA-256 of the the serialized event data>,
+ "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
+ "created_at": ,
+ "kind": 34235 | 34236,
+ "content": "",
+ "tags": [
+ ["d", ""],
+ ["title", ""],
+ ["published_at", ""],
+ ["alt", ""],
+
+ // 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", ],
+ ["content-warning", ""],
+
+ // origin tracking for imported content
+ ["origin", "", "", "", ""],
+
+ // participants
+ ["p", "<32-bytes hex of a pubkey>", ""],
+
+ // hashtags
+ ["t", ""],
+ ["t", ""],
+
+ // reference links
+ ["r", ""]
+ ]
+}
+```
+
+## Referencing Addressable Events
+
+To reference an addressable video:
+
+```
+["a", "34235::", ""] // for normal videos
+["a", "34236::", ""] // 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: "" } — 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