Promote beta → main: opt-in keep-unlocked (signer/storage/NWC) + lazy re-unlock across all signing ops (#51/#54/#57), account-switch modal refresh, wallet+storage sync unification, hashtag monotonic created_at + upload tag suggestions (#45), live/shorts/bridge dev plans

See the corresponding "Promote unstable → beta" commit for the full change list.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-01 21:04:49 -04:00
commit ac1b3d3673
79 files changed
+7515 -557

No files matched your search

+131 -2
View File
@@ -373,10 +373,31 @@
</svg>
Admin (Moderation)
</button>
<button
id="profileMobileLockNowBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden hidden"
title="Forget your unlocked key on this device and re-lock storage + wallet now"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Lock this device
</button>
<button
id="profileMobileLogoutBtn"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-6 lg:hidden"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium mt-2 lg:hidden"
data-variant="critical"
>
<svg
@@ -1693,6 +1714,59 @@
</p>
</div>
<div
id="adminSubtabBar"
class="flex flex-wrap gap-2 border-b border-border/60 pb-3"
role="tablist"
aria-label="Admin tools"
>
<button
id="adminSubtabWhitelist"
type="button"
role="tab"
data-admin-subtab="whitelist"
aria-controls="adminWhitelistSection"
aria-selected="true"
data-state="active"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Whitelist
</button>
<button
id="adminSubtabBlacklist"
type="button"
role="tab"
data-admin-subtab="blacklist"
aria-controls="adminBlacklistSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Blacklist
</button>
<button
id="adminSubtabBlockedVideos"
type="button"
role="tab"
data-admin-subtab="blockedVideos"
aria-controls="adminBlockedVideosSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
>
Blocked videos
</button>
<button
id="adminSubtabModerators"
type="button"
role="tab"
data-admin-subtab="moderators"
aria-controls="adminModeratorsSection"
aria-selected="false"
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium hidden"
>
Moderators
</button>
</div>
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
@@ -1790,7 +1864,7 @@
<ul id="adminWhitelistList" class="grid-stack" data-orientation="vertical"></ul>
</div>
<div id="adminBlacklistSection" class="space-y-4">
<div id="adminBlacklistSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Blacklist</h4>
@@ -1836,6 +1910,53 @@
</div>
<ul id="adminBlacklistList" class="grid-stack" data-orientation="vertical"></ul>
</div>
<div id="adminBlockedVideosSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Blocked videos</h4>
<button
id="adminBlockedVideosRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh blocked videos"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
</div>
<p class="text-sm text-muted">
Individual videos hidden everywhere on bitvid without blocking their creator. Block a video from its ⋯ menu, or paste an event id / nevent below.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="adminBlockedVideoInput">
<span class="mb-2 block font-medium">Event id or nevent</span>
<input
id="adminBlockedVideoInput"
type="text"
inputmode="text"
autocomplete="off"
class="input"
placeholder="nevent1… or hex event id"
/>
</label>
<button
id="adminAddBlockedVideoBtn"
type="button"
class="btn focus-ring"
>
Block video
</button>
</div>
<div
id="adminBlockedVideosEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-sm text-muted"
>
No videos are individually blocked yet.
</div>
<ul id="adminBlockedVideosList" class="grid-stack" data-orientation="vertical"></ul>
</div>
</section>
</div>
</div>
@@ -1843,6 +1964,14 @@
<!-- Modal Footer -->
<div class="hidden lg:flex items-center justify-end gap-3 border-t border-border/60 p-4">
<button
id="profileLockNowBtn"
type="button"
class="btn-ghost focus-ring hidden"
title="Forget your unlocked key on this device and re-lock storage + wallet now"
>
Lock this device
</button>
<button id="profileLogoutBtn" class="btn focus-ring" data-variant="danger">
Logout
</button>
+4
View File
@@ -249,6 +249,10 @@
<!-- Hashtags -->
<div class="space-y-2">
<label class="block text-sm font-medium text-text">Hashtags</label>
<div id="hashtag-suggestions-wrap" class="space-y-1 hidden">
<p class="text-xs text-muted">Reuse a tag from your videos:</p>
<div id="hashtag-suggestions" class="flex flex-wrap gap-2"></div>
</div>
<div
class="rounded-lg border border-border/70 bg-surface/30 p-3 space-y-3"
data-nip71-repeater="t"
+335
View File
@@ -0,0 +1,335 @@
# BitVid Bridge — cross-platform streaming helper app — Dev Plan
Companion to **`docs/live-publish-plan.md`** (#16c) — this expands its **Phase 7
(Local Bridge app)** into its own plan. The Bridge is the piece that fills the one
gap a static web client can't: **receiving and routing live media** on the
creator's machine.
Status: **FUTURE / exploratory — all design decisions LOCKED** (maintainer
accepted every recommendation), but **DELIBERATELY DEFERRED** — see the priority
principle below.
> **⛔ PRIORITY PRINCIPLE — static bitvid first (maintainer).** Put as much effort
> into the **static bitvid client** as possible before branching into any *separate
> application*. The Bridge is a second app (own repo, toolchain, cross-platform
> packaging, code-signing cost), so **do not start it** until the static client's
> live features have shipped and are maximally developed — specifically the Go-Live
> **Tier-1/2 MVP** (external-provider + serverless, zero infra; see the publish
> plan's provider spectrum). Rule of thumb: **exhaust what the static client can do
> before writing a second app.** Build the Bridge only once live has shipped *and*
> shown real demand for the sovereignty/power features it adds (local recording +
> full-WebTorrent VOD, no-third-party, multi-restream, hardware/appliance, capture).
Goal (maintainer, *when it's time*): a **super-simple, easy-to-install,
easy-to-use** cross-platform app (Windows / macOS / Linux) that makes going live on
bitvid turnkey.
---
## Core decision
The Bridge is a **local media receiver + router**, not a Nostr identity holder.
It pairs a **native media core** (receive/record/restream/upload) with an
**embedded bitvid "Studio" webview** that does all Nostr signing. The two stay
logically separated inside one app:
```
BitVid Bridge (one installable app)
├── Native media core → RTMP/SRT/WHIP ingest, HLS/WebRTC out, record, restream,
│ S3 upload (never sees the Nostr key)
└── Embedded Studio webview → bitvid client that signs NIP-53 + publishes VOD
(talks to the core over in-app IPC, not localhost HTTP)
```
**Why an embedded webview instead of "the website controls a localhost server":**
it solves two hard problems at once —
1. **No browser↔localhost pain.** An HTTPS page calling `http://127.0.0.1` hits
mixed-content + Private-Network-Access preflight/permission rules. In-app IPC
(Tauri commands / Wails bindings) sidesteps all of it.
2. **Signing stays in the web layer.** The webview is a bitvid client, so it signs
exactly as the browser does; the native core never touches the key. This
honors the publish plan's Rule 1 ("BitVid signs; Bridge routes") while still
being one self-contained app.
**Where the Bridge sits (see the publish plan's three-tier provider spectrum):**
- **Tier 1 (platform embed)** and **Tier 2 (serverless RTMP→HLS: Livepeer/Mux/
Cloudflare)** need **no Bridge and no VPS** — Tier 2 is the recommended
"no-server, native playback" default. So the Bridge is the **Tier-3 (advanced /
full-sovereignty)** option, not the default on-ramp.
- **The Bridge earns its place** by: no third party at all, **local recording +
full-WebTorrent VOD**, multi-restream hub, hardware/appliance input, turnkey UX,
and later built-in capture (no OBS).
**Hybrid that avoids a VPS entirely (important):** a **local** Bridge can
**record locally + restream to a Tier-2 serverless provider** which serves the
public HLS. The home Bridge then **never needs to be publicly reachable** (no VPS,
no tunnel/port-forward — it's *outbound* to the provider), yet the creator still
gets local recording + a full-WebTorrent VOD, and viewers get native HLS. This is
the sweet spot for most sovereignty-minded creators: **local Bridge for
record/WebTorrent/control + serverless for reach.**
---
## Decisions — ✅ ALL LOCKED
The maintainer accepted every recommendation below; each *Recommendation* line is
now the **locked choice**. Summary:
| # | Decision | Locked |
|---|----------|--------|
| 1 | App framework | **Wails (Go)** — one Go binary for desktop + headless node (ties to D7); Tauri was the close alt |
| 2 | Control model | **Embedded Studio webview** primary; optional localhost API later |
| 3 | Capture vs receive | **Receiver-first MVP**; built-in capture as a fast-follow (Phase 5) |
| 4 | Webview signer | **NIP-46 + encrypted-nsec**, NIP-46 preferred |
| 5 | Ingest protocols | **RTMP for MVP**; SRT + WHIP later |
| 6 | Recording upload | **Bundled rclone** (multi-provider, resumable) |
| 7 | One codebase, two modes | **Yes** — desktop Bridge == self-hosted Media Node run `--headless` |
| 8 | Transcode | **Pass-through only** (`-c copy`) |
| 9 | Code signing | **Project owns** Apple notarization + Windows cert (budgeted) |
| 10 | License / telemetry | **Match bitvid; no telemetry by default** |
D1 resolves to **Go/Wails** specifically because D7 (one codebase, two modes) is a
"yes": a single Go binary serves the desktop Bridge (with UI) and the headless
self-hosted Media Node — Go being MediaMTX's own language makes that clean.
> **DECISION 1 — App framework.** Go/**Wails** vs **Tauri** vs Electron.
> *Recommendation: **Wails (Go)** or **Tauri (Rust)** — both give a web UI (reuse
> bitvid's components/tokens) and small binaries; Electron is ~100MB+ and heavy,
> against "simple install." Slight lean **Wails/Go**: the media stack is Go-native
> and **MediaMTX is written in Go**, so it's one language, one small static binary,
> trivial cross-compile. Tauri is a very close second with a more mature app-shell
> + updater. Pick based on team comfort (Go vs Rust).*
> **DECISION 2 — Control model.** Embedded Studio webview (self-contained app) vs
> website-drives-localhost-API vs both.
> *Recommendation: **embedded webview primary** (dodges PNA/mixed-content + keeps
> signing in the web layer). Optionally expose a **localhost API later** for people
> who want to drive it from the bitvid website (with the PNA/CORS work done then).*
> **DECISION 3 — Capture vs receive.** MVP receives (OBS/hardware → Bridge) vs push
> built-in screen/camera capture earlier.
> *Recommendation: **receiver-first MVP** — works with the OBS/encoders people
> already have and ships fast. **Built-in capture (no OBS)** is the "super simple"
> headline but is effectively a mini-OBS (encoder is hard) → make it a fast-follow
> phase, not MVP.*
> **DECISION 4 — Signer support in the Bridge webview.** Since the webview signs,
> which login methods? **NIP-46 remote signer** (no key on device — safest), the
> **encrypted-nsec** flow the web app already has (key encrypted on-device), or
> both. NIP-07 extensions generally aren't available inside an app webview.
> *Recommendation: **both NIP-46 + encrypted-nsec**, NIP-46 preferred/first. Reuse
> the web app's existing session/signer code so there's nothing new to audit.*
> **DECISION 5 — Ingest protocols for MVP.** RTMP only, or RTMP + SRT + WHIP.
> *Recommendation: **RTMP for MVP** (universal — OBS/hardware all speak it), add
> **SRT** (better over lossy networks) and **WHIP** (WebRTC ingest, low latency)
> in a later phase. MediaMTX supports all three, so it's mostly UI/config.*
> **DECISION 6 — Recording upload mechanism.** Bundle **rclone** (one binary,
> speaks R2/B2/S3/MinIO + resumable) vs a native S3 SDK in the core vs reuse
> bitvid's browser S3 upload from the webview.
> *Recommendation: **rclone** bundled as a sidecar — dead-simple multi-provider
> support + resumable uploads of large recordings, matches the storage providers
> bitvid already supports. Native SDK only if we want to drop the extra binary.*
> **DECISION 7 — One codebase, two modes?** The server-side **Media Node** (#16c)
> and the local **Bridge** are ~the same thing (MediaMTX + session API + upload +
> restream). Build **one codebase that runs headless (Media Node) or with the
> embedded UI (Bridge)?**
> *Recommendation: **yes** — "self-host the Media Node" (publish-plan DECISION 1b)
> becomes literally "run the Bridge in `--headless` mode on a VPS." Big reuse win;
> steers DECISION 1 toward Go (server + desktop from one Go binary).*
> **DECISION 8 — Transcode.** Pass-through (`-c copy`) only, or offer local
> transcode profiles?
> *Recommendation: **pass-through only** (same as the publish plan). Require
> H.264/AAC in; the Bridge repackages/forwards without re-encoding — keeps CPU low
> and the app light. Transcoding is a later, opt-in power feature.*
> **DECISION 9 — Code signing + distribution ownership.** "Easy install" *requires*
> **Apple notarization** (Developer ID — or macOS Gatekeeper blocks it) and a
> **Windows Authenticode cert** (or SmartScreen scares users). Who holds the certs
> and pays (~$99/yr Apple + Windows cert)? Linux = AppImage + `.deb`/`.rpm`
> (no signing gate). *Recommendation: the project owns the certs; budget for them —
> this is the true cost of "easy to install," not optional.*
> **DECISION 10 — License / telemetry.** Open-source license (match bitvid's) and
> **no telemetry by default** (opt-in crash reports at most). *Recommendation:
> same license as bitvid; privacy-first, opt-in only.*
---
## Architecture
```
BitVid Bridge
├── App shell (Tauri/Rust or Wails/Go) DECISION 1
│ └── Embedded Studio webview (bitvid client, signs NIP-53/VOD) DECISION 2,4
├── Native media core
│ ├── MediaMTX (bundled sidecar) → ingest RTMP/SRT/WHIP, HLS/WebRTC out, record
│ ├── FFmpeg (bundled) → copy-forward restreams (-c copy) DECISION 8
│ └── rclone (bundled) → upload recordings to R2/B2/S3/MinIO DECISION 6
├── Session/state manager → LiveSession model, status, tokens
├── IPC bridge → webview ↔ core (Tauri cmds / Wails bindings)
└── (optional) localhost API → for website control, later DECISION 2
```
- **Studio webview sourcing.** The webview loads the **instance's deployed bitvid
web app** (remote URL, cached for offline resilience) rather than a bundled copy.
Benefit: it's always current and **auto-inherits the instance's flags** (so
`FEATURE_LIVE_PUBLISH` off ⇒ no go-live, invariant 6) with no app update needed;
a trimmed bundled fallback covers the offline/first-run case. The instance URL is
configurable (a user may use more than one bitvid instance).
- **Media core = a local Media Node.** Same engine as the server-side node (#16c),
which is why DECISION 7 (one codebase, two modes) is attractive.
- **Stream keys / ingest URLs never leave the device** and never go into Nostr; the
webview only ever publishes the *playback* URL.
- **Status → Nostr:** MediaMTX hooks fire `input_connected / input_live /
input_lost / recording_finalized / upload_complete`; the core forwards these to
the webview over IPC; the **webview signs** the corresponding NIP-53 update
(planned → live → ended + recording).
---
## UX flow (the "super simple" target)
```
1. Install (signed installer / AppImage). DECISION 9
2. Open app → sign in (NIP-46 or encrypted nsec) in the Studio webview. DECISION 4
3. Fill title/thumbnail/tags → "Prepare stream" (publishes status=planned).
4. App shows one of:
a. RECEIVE MODE: local ingest URL + key to paste into OBS/encoder. DECISION 3
b. (later) CAPTURE MODE: pick a screen/window/camera → "Go Live".
5. Encoder connects → core detects input → webview publishes status=live.
6. (optional) toggle restream outputs (YouTube/Twitch/zap.stream). DECISION 5
7. "End" → status=ended → core finalizes recording → uploads → webview
offers "Publish recording as a BitVid video" (archive→VOD).
```
The whole point: **one window, sign in, go live, done** — and the recording
becomes a normal bitvid video automatically.
---
## Cross-platform packaging
- **Windows:** signed `.msi`/`.exe` (Authenticode). Auto-update.
- **macOS:** signed **+ notarized** `.dmg` (Developer ID) — mandatory. Universal
(arm64 + x86_64).
- **Linux:** **AppImage** (zero-install portable) + `.deb`/`.rpm`; consider Flatpak.
- **Bundled binaries per-platform:** MediaMTX, FFmpeg, rclone. FFmpeg is the size
hog (~50–80MB) — ship a **minimal build** (only the muxers/protocols we use:
flv/rtmp/mpegts/hls/webrtc, aac/h264 passthrough) to keep the installer lean.
- **Auto-updater** (both Tauri and Wails have one) with signed release artifacts.
---
## Phases
- **Phase 0 — Shell + media core (foundational).** App skeleton (DECISION 1),
bundle + supervise MediaMTX, embedded Studio webview, webview↔core IPC, signed
dev builds on all three OS. Sign-in works (DECISION 4).
- **Phase 1 — Receive → serve (the MVP).** Local RTMP ingest → serve HLS. For
public viewers the playback origin must be **reachable**, so either run the
Bridge **headless on a reachable host** (VPS — the self-hosted node) or
copy-forward from a local Bridge to that reachable node / an external provider
(never a bitvid-run node). Publish planned→live→ended NIP-53 from the webview via
MediaMTX input hooks. "OBS → your node → bitvid, one window."
*(NAT note: a home-only Bridge can't serve public HLS directly → either run
headless on a VPS, or use the hybrid: forward to a Tier-2 serverless provider
that serves public HLS — see Phase 3.)*
- **Phase 2 — Record → archive → VOD.** Local recording, finalize, rclone upload
to the user's storage, manifest → webview publishes a normal bitvid video
(reuses the `s`-tag/info.json/30078 flow). Optional torrent/webseed for full
WebTorrent VOD.
- **Phase 3 — Multi-restream (`FEATURE_LIVE_RESTREAM`).** One input → many outputs
(YouTube/Twitch/zap.stream/**serverless providers Livepeer/Mux/Cloudflare**/
custom RTMP/SRT), copy-only, per-output status + retry/backoff. Keys stay local;
optional public mirror links as `r` tags. **The serverless preset enables the
no-VPS hybrid** — a local Bridge records locally + forwards to Livepeer/Mux,
which serves the public HLS (the `streaming` URL in the NIP-53 event). This is
arguably the flagship Bridge workflow for home creators.
- **Phase 4 — Turnkey UX + packaging.** One-click go-live, OBS auto-config or
OBS-WebSocket control, signed/notarized installers + AppImage, auto-update.
- **Phase 5 — Built-in capture (no OBS).** Screen/window/camera capture + encode
inside the app (`getDisplayMedia`/`getUserMedia` → WebCodecs/FFmpeg). The "super
simple" headline: go live with no external encoder.
- **Phase 6 — SRT/WHIP ingest + WebRTC low-latency** (DECISION 5).
- **Phase 7 — Headless "Media Node" mode + hardware / "BitVid Box".** Run the same
binary headless as the self-hosted Media Node (DECISION 7); harden for
appliance/headless use — the seed of a physical device.
---
## Engineering rules (invariants)
1. **The native core never sees the Nostr key.** Only the webview signs.
2. **Ingest URLs/keys stay on the device; only playback URLs reach Nostr.**
3. **Pass-through first** — copy H.264/AAC, don't transcode (DECISION 8).
4. **Reuse bitvid's web client** for the Studio webview (session, signer, publish,
upload-modal-style VOD flow) — minimize net-new signing/auth code.
5. **Optional, not required** — a creator can still stream OBS → an external
provider without the Bridge; the Bridge *adds* self-hosting + convenience/power
(and bitvid never runs a node — blanket rule).
6. **Respect the connected instance's config.** The embedded Studio webview loads
a specific bitvid instance, so it inherits that instance's flags — if the
instance has `FEATURE_LIVE_PUBLISH` **off**, the Bridge shows go-live as
unavailable for it (nothing to publish to). The Bridge is only useful pointed
at an instance that has publishing enabled; surface that clearly rather than
failing opaquely.
7. **Bind the local ingest to loopback only.** The RTMP/SRT receiver listens on
`127.0.0.1` (not `0.0.0.0`) by default so it isn't exposed to the LAN;
appliance/headless mode (Phase 7) can opt into a wider bind with auth.
---
## Risks / watch-items
- **Code signing is the real "easy install" cost (DECISION 9).** Apple
notarization + Windows cert are non-negotiable for a frictionless install and
cost money + setup. Budget for them up front.
- **Bundle size vs simplicity.** MediaMTX + FFmpeg + rclone add up; a minimal
FFmpeg build and per-platform packaging keep it reasonable, but it won't be
tiny. Trade-off vs "super light."
- **Built-in capture is a mini-OBS (DECISION 3/Phase 5).** Reliable capture +
encode across GPUs/OSes is genuinely hard — don't let it block the receiver MVP.
- **Signing during long streams.** Same issue as the publish plan (DECISION 2
there): the webview must stay running to sign status updates + heartbeats;
handle sleep/crash gracefully (NIP-53 treats no-update-in-~1h as ended).
- **Webview signer support.** NIP-07 extensions typically aren't present in an app
webview → lean on NIP-46 / encrypted-nsec (DECISION 4).
- **Encoder compatibility.** Pass-through needs H.264/AAC + sane keyframes;
detect + warn on incompatible input rather than failing silently.
- **Scope creep.** This app can sprawl (capture, transcode, appliance). Ship the
receiver MVP (Phase 0–1) first; everything else is additive.
- **Instance without publishing enabled.** If a user points the Bridge at an
instance that has `FEATURE_LIVE_PUBLISH` off, go-live must degrade gracefully
(clear "publishing isn't enabled on this instance" state), not error obscurely
(invariant 6).
- **Local ingest exposure.** A misconfigured wide bind would expose an unauth'd
RTMP receiver on the network — default to loopback (invariant 7); require auth
for any non-loopback bind.
- **Reachability / NAT (self-hosting reality) — largely solved by the hybrid.** A
**home-only Bridge behind NAT can't *serve* public HLS**. Two clean answers:
(1) the **hybrid** — the local Bridge *forwards* (outbound) to a Tier-2
serverless provider that serves the public HLS, so the home box needs no public
reachability at all (recommended for laptops/home); (2) run the Bridge
**headless on a reachable VPS** for full self-hosting (no third party). Only (2)
needs a VPS; document both. This is why headless mode (Phase 7 / DECISION 7) and
restream-to-serverless (Phase 3 preset) both matter.
---
## Sources
- **MediaMTX** (ingest/playback/record/forward/control API):
https://mediamtx.org/docs/kickoff/introduction
- **OBS WebSocket** (built into OBS 28+):
https://obsproject.com/kb/remote-control-guide
- App frameworks: [Tauri](https://tauri.app/) · [Wails (Go)](https://wails.io/) ·
packaging binaries (MediaMTX, [FFmpeg](https://ffmpeg.org/),
[rclone](https://rclone.org/)).
- Companion plans: `docs/live-publish-plan.md` (#16c, this expands its Phase 7),
`docs/live-ingest-plan.md` (#16, watch side + P2P-assisted HLS).
- bitvid code reused by the Studio webview: session/signer (NIP-46 + encrypted
nsec), the upload/publish flow, `js/utils/storagePointer.js` (`s`-tag →
`.info.json`) for archive→VOD.
+327
View File
@@ -0,0 +1,327 @@
# Live Streams — INGEST / watch-only — Dev Plan
TODO ref: **#16** in `todo/TODO_2026-06-20_pre-launch.md` (watch-only).
Publishing / "Go Live" is **#16c** — a separate, larger effort with its **own**
config flag; it is explicitly **out of scope** for this doc.
Status: **DECISIONS LOCKED (D1–D5)** — Phase 0 research spike first (confirm tag
shapes + hls.js/CORS + default relays), then build. Not started.
Live streams are **NIP-53 Live Activities** — a *different* NIP from short-form
video (#16b / NIP-71). Kind **30311** is the live event (addressable, carries the
`streaming` URL — usually HLS `.m3u8` — plus `status`, title, host `p` tags);
kind **1311** is live chat. zap.stream and shosho.live publish these.
Scope of this feature: **discover live streams, play the HLS stream, chat
(read → post, phased), and zap the host.** No broadcasting, no media hosting.
Everything ships behind a config flag that leaves **no trace** when off.
---
## Decisions needed
> **DECISION 1 — Listing scope. ✅ LOCKED: Option A (whitelisted hosts only).**
> The Live tab lists only kind-30311 events whose host is a whitelisted/trusted
> author, reusing the existing whitelist/WoT filter — no new moderation surface,
> consistent with the main feed + Shorts. Accepted trade-off (maintainer): live
> is sparse, so the tab may be empty when no whitelisted creator is streaming;
> that's expected with a whitelist (ship a clear "no one you follow is live right
> now" empty state). A broader "discover live" surface can come later.
> **DECISION 2 — Chat level. ✅ LOCKED: read + post + zaps as the end state,
> reached in phases.** Target is full participation (read chat, post chat, zap the
> host). Phased so playback ships first and posting (a write path) lands cleanly:
> - **Phase 3** — read-only kind-1311 chat **+ host zaps** (zaps via the existing
> NWC/zap system; active participation from day one of chat).
> - **Phase 3b** — **post chat**: sign/publish kind-1311 into the stream, behind
> its own sub-flag (`FEATURE_LIVE_CHAT_POST` or similar), with a moderation
> stance (rate-limit; you're publishing into someone else's stream — decide how
> blocked/muted authors and your own outbound spam are handled).
> Rationale (maintainer): end up at read+post+zaps; phasing keeps the write path
> and its moderation questions out of the initial playback release.
> **DECISION 3 — HLS playback dependency. ✅ LOCKED: Option A (hls.js,
> lazy-loaded behind the flag).** Native `<video>` HLS only works in Safari, so a
> library is required for Chrome/Firefox/Edge; hls.js is the mature standard.
> Maintainer deferred to recommendation. Implementation guardrails:
> - **Vendored**, not a runtime CDN import (bitvid ships assets locally; keeps it
> working offline/self-hosted and avoids a third-party origin dependency).
> - **Dynamically imported only when the live player opens** — never loaded when
> `FEATURE_LIVE_INGEST` is off (honors "off = no trace"), and not on the
> critical path for non-live pages.
> - Prefer native HLS where available (Safari) and fall back to hls.js elsewhere,
> so Safari users don't pay for the library.
> - Precedent: bitvid already ships WebTorrent (a much heavier dep), so one small
> well-established HLS lib is proportionate.
> **DECISION 4 — Past streams / VOD presentation. ✅ LOCKED: Option A (one Live
> tab: live first, then recent ended).** Currently-live streams sort to the top;
> recently-ended streams follow, clearly badged "Ended" and playing their
> `recording` VOD (normal scrubber) through the same HLS path. Bonus: this
> **mitigates the sparse-live empty-tab concern from DECISION 1** — a whitelisted
> creator's recent past streams keep the tab populated between live sessions.
> A separate "Past streams" section only if the single list gets crowded.
> **DECISION 5 — Discovery relays. ✅ LOCKED: Option B (user read relays + a
> configurable set of live-discovery relays).** Query kind-30311 on the user's
> read relays PLUS a small instance-config list of known stream relays
> (default a couple of well-known ones, e.g. zap.stream's relay; editable per
> deployment). Widens *where* we look without widening *who* — DECISION 1's
> whitelist still gates the hosts shown. Extra relays are only connected when
> `FEATURE_LIVE_INGEST` is on, capped/deferred per the relay-storm invariants.
> Phase 0 confirms which stream relays to ship as defaults.
---
## Config flags (off = no trace)
Flags introduced by this doc (both default `false`, threaded through
`js/config.js` → `js/constants.js` like `FEATURE_NIP71_INGEST`):
- **`FEATURE_LIVE_INGEST`** — the whole watch-only feature (tab, discovery,
playback, read chat, host zaps).
- **`FEATURE_LIVE_CHAT_POST`** — the Phase 3b write path (posting chat); only
meaningful when ingest is also on. Kept separate so an instance can allow
watching + reading chat without enabling outbound posting.
(The `FEATURE_LIVE_PUBLISH` "Go Live" flag belongs to **#16c**, a separate doc.)
When `FEATURE_LIVE_INGEST` is off, all of the following must be absent:
1. **Sidebar tab** — the "Live" link is not rendered in `components/sidebar.html`.
2. **Feed/view registration** — `FEED_TYPES.LIVE` is not registered.
3. **Subscriptions** — no kind-30311 discovery subscription and no kind-1311 chat
subscription are ever created.
4. **Route** — `#view=live` falls back to the default view.
5. **HLS library** — hls.js is dynamically imported only inside the live player,
so it never loads when the flag is off (keeps the bundle/network clean).
6. **Chat-post UI** — the compose box (Phase 3b) is absent unless BOTH
`FEATURE_LIVE_INGEST` and `FEATURE_LIVE_CHAT_POST` are on.
7. **Config surface** — the flags above are the only footprint.
> **Management UI note:** a **"Live" sub-tab in profile → My Videos** (alongside
> Videos / Shorts, reusing the admin sub-tab pattern) is for managing the user's
> OWN streams — so it belongs to **publishing (#16c / `FEATURE_LIVE_PUBLISH`)**,
> not this ingest feature. With ingest-only enabled there is nothing of the user's
> to manage, so no My-Videos Live sub-tab appears. (See the shorts plan's
> "Publish & management UI" section for the shared sub-tab treatment.)
---
## Field mapping — kind 30311 (to VERIFY in Phase 0)
Expected tag shape (confirm against captured real events before building):
| Tag | Purpose |
|-----|---------|
| `d` | unique identifier (addressable key with pubkey+kind) |
| `title` / `summary` | stream title / description |
| `streaming` | **HLS `.m3u8` playback URL** (may appear more than once) |
| `recording` | VOD URL when ended |
| `status` | `planned` / `live` / `ended` |
| `starts` / `ends` | unix timestamps |
| `image` | thumbnail/poster |
| `p` (role `host`/`participant`) | host + participants (pubkeys) |
| `service` | provider hint (zap.stream etc.) |
| `current_participants` / `total_participants` | viewer counts (optional) |
Kind 1311 (live chat): a note tagged with the 30311 `a`-address; content is the
chat message. Read-only ingest = subscribe by `#a`, render newest.
> These are the *documented* shapes; **Phase 0 captures real events** from
> zap.stream and shosho.live to confirm exact keys, multiplicity of `streaming`,
> and how each provider marks live vs ended.
---
## Architecture
- **Flag:** `FEATURE_LIVE_INGEST` (instance-config → config → constants).
- **Feed type:** add `LIVE: "live"` to `FEED_TYPES` (`js/constants.js`).
- **Discovery source:** a new live service (e.g. `js/services/liveIngestService.js`)
subscribing to kind 30311 (`status=live`, scoped per DECISION 1), normalizing
events into a lightweight "live card" model (title, host, image, streaming URL,
status, viewer count). Mirrors the deferred/throttled pattern of
`nip71IngestService` so it stays off the cold-start critical path.
- **Sidebar tab + view:** "Live" link in `components/sidebar.html` (flag-gated) +
`views/live.html` (grid of live cards).
- **Card design (new):** a **live card** distinct from `VideoCard.js` — a **LIVE
badge**, viewer count, host name/avatar, and an "ended" state for past streams
(Phase 4). Likely a dedicated `LiveCard` (the live/ended metadata differs enough
from a video card that a variant is awkward). New `data-testid` for e2e.
- **Player:** a live player path. The current player
(`js/services/playbackService.js`) sets `videoEl.src` for URL/WebTorrent; HLS
needs hls.js attached to the `<video>` element when the source is `.m3u8`
(native only on Safari). Lazy-load hls.js on first live play (DECISION 3).
- **Chat (DECISION 2):** read-only kind-1311 subscription by `#a` (Phase 3),
rendered beside the player with WoT-mute filtering; a compose/post path in
Phase 3b behind `FEATURE_LIVE_CHAT_POST`; teardown on close.
- **Liveness/staleness:** an event with `status=live` but no recent update / dead
`.m3u8` should be de-emphasized or dropped (see Risks).
---
## P2P-assisted playback (future — bitvid's bandwidth edge)
Q: "Can live use WebTorrent?" **Classic WebTorrent can't carry a *live* stream** —
a torrent's infohash is computed from the *complete* file, and live content
doesn't exist yet. bitvid's WebTorrent is progressive playback of a finished VOD,
not live. **But P2P *live* is real** via a different mechanism: **HLS + peer-to-peer
segment sharing.** hls.js loads segments while a WebRTC swarm shares them among
concurrent viewers, so the origin serves each segment fewer times.
- **Concrete stack:** [P2P Media Loader](https://github.com/novage/p2p-media-loader)
— hls.js-compatible, and historically used **WebTorrent trackers** for peer
discovery. It's a drop-in segment engine for the Phase-2 hls.js player: the
"WebTorrent-flavored" path for live.
- **bitvid advantage:** every extra concurrent viewer helps *serve* the stream
instead of only consuming origin bandwidth — cutting the Media Node's egress
(the biggest cost/risk of #16c) and matching bitvid's P2P ethos.
- **Why NOT v1:** P2P offload only pays off with **enough concurrent viewers of
the same stream**. DECISION 1 scopes live to whitelisted hosts → likely
low-concurrency/sparse, where a swarm adds complexity + a little latency (on top
of HLS's 6–30s) for little gain. **v1 = plain hls.js; add P2P-assisted HLS
later, gated on real concurrency.**
- **VOD keeps full WebTorrent:** once a stream ends, its recording is an ordinary
bitvid video (complete file → infohash) and gets the normal seed/webseed
treatment (see the publish plan's archive→VOD flow).
---
## Live watch UX best practices (researched July 2026)
Unlike shorts, live is a **landscape 16:9, "cinematic"** experience, and the watch
page is a well-established pattern (Twitch / YouTube Live). Sources at the bottom.
### Desktop presentation (cinematic)
- **Player + chat side rail.** 16:9 player on the left/center, a **collapsible chat
rail on the right**. Below the player: title, **host (avatar + name + follow/
subscribe)**, **LIVE badge + viewer count**, elapsed time, description/tags.
- **Theater mode** (player widens, chat stays docked) and **fullscreen** (chat
becomes an optional overlay or hides) — both standard viewer expectations.
- Keyboard: **Space** pause, **F** fullscreen, **T** theater, **M** mute.
### Mobile presentation
- **Player on top (16:9), chat below** in a scrollable panel. In landscape /
fullscreen, chat becomes a **resizable/draggable overlay** (the current Twitch/
YouTube mobile pattern) rather than eating the video. Picture-in-picture
"keep playing while I browse" is a nice-to-have, not v1.
### Live-specific affordances
- **Prominent red LIVE badge + viewer count** (from 30311 `current_participants`)
and elapsed-since-`starts`. These are retention cues, keep them visible.
- **Latency is real and must be expected:** standard **HLS is ~6–30s behind**
real-time (LL-HLS 1–5s, WebRTC <500ms). zap.stream serves HLS, so **chat will
run ahead of the video** — don't promise low-latency, and don't try to hard-sync
chat to the frame.
- **Auto-scrolling chat** that **pauses when the user scrolls up**, with a
"jump to latest" affordance (Phase 3).
- **Live vs ended seeking:** a live stream has only edge/limited seeking; an
**ended stream plays its `recording` as a normal VOD** with a full scrubber
(Phase 4).
### bitvid-specific notes
- **Zaps fit live perfectly** — zap.stream's whole model is zapping streamers, and
bitvid already has the NWC/zap system. Live zaps to the host are a natural
headline action (surface them alongside chat). Even if chat is read-only in
Phase 3, zapping the host can ship earlier. **Zap target:** resolve from the
host's profile `lud16`/LNURL, or a zap tag on the 30311 event if present
(confirm in Phase 0); reuse the existing zap flow — no new payment code.
- Reuse the existing player shell where practical; HLS via lazy hls.js (DECISION 3).
- Respect `prefers-reduced-motion` and the relay-cap / circuit-breaker invariants
for the discovery + chat subscriptions.
## Phases (each flag-gated from day one)
- **Phase 0 — Research spike (do FIRST, small but essential).**
- Capture real kind-30311 events from zap.stream + shosho.live; confirm tags,
`streaming` multiplicity, live/ended marking, host `p` roles.
- Prototype HLS playback in a throwaway page: hls.js attaching to `<video>`
with a real `.m3u8`; confirm CORS behavior from the static origin.
- Pick the default **live-discovery relays** (DECISION 5) from where real
30311 events actually land; resolve the **host zap target** (lud16/LNURL vs a
`zap` tag on the event).
- Output: **confirm** the field mapping + defaults (decisions are already
locked); flag any surprises back into this doc.
- **Phase 1 — Discovery + Live tab (medium).** Flag, `FEED_TYPES.LIVE`, live
service (30311 discovery, scoped), sidebar link, `views/live.html`, grid of
live cards, route gated by flag. Clicking a card opens… (Phase 2).
- **Phase 2 — HLS playback + cinematic layout (medium).** Live player path + lazy
hls.js; play the `streaming` URL; **desktop** player + collapsible chat rail
(rail empty until Phase 3), theater/fullscreen, LIVE badge + viewer count;
**mobile** player-top / chat-below; keyboard controls; graceful error when the
stream is down. Set latency expectations (HLS ~6–30s).
- **Phase 3 — Read chat + host zaps (medium, DECISION 2).** Read-only kind-1311
subscription by `#a`, auto-scroll with pause-on-scroll-up + jump-to-latest,
teardown on close. **Host zaps** via the existing NWC/zap system (active
participation from the start).
- **Phase 3b — Post chat (medium, DECISION 2).** Sign/publish kind-1311 into the
stream, behind its own sub-flag, with a moderation stance (rate-limit; how
blocked/muted authors + outbound spam are handled). This is the committed end
state (read + post + zaps).
- **Phase 4 — Past streams / VOD (medium, DECISION 4).** List `status=ended`
events and play their `recording` URL through the same HLS player; mark ended
vs live in the UI. This is the "past zap.stream streams" capability.
- **Phase 5 — Polish.** Poster/skeleton states, liveness de-emphasis for stale
events, high-volume-chat buffering, and (optional) surfacing `planned`/upcoming
streams.
---
## Moderation, whitelist & NSFW
Per DECISION 1 (locked: whitelisted hosts): the live list is scoped to
whitelisted authors and reuses the existing trust filter, so the admin
whitelist/blacklist governs which streams appear.
**Chat moderation (two design items, before their phases):**
- **Read chat (Phase 3):** kind-1311 messages come from *arbitrary* viewers, not
just whitelisted authors. Decide whether the read view applies the existing WoT
**mute/blacklist** (hide messages from muted/blocked pubkeys) — recommend yes,
reuse the same author filter so a globally-blocked spammer is hidden in live
chat too. NSFW/text moderation of chat is out of scope for v1.
- **Post chat (Phase 3b):** you're publishing into someone else's stream — add
rate-limiting and respect the host/relay's own rules; decide the outbound stance
(e.g. block posting for lockdown/blacklisted local users).
---
## Risks / watch-items
- **HLS is new to the player.** Native `.m3u8` works only in Safari; Chrome/
Firefox need hls.js. This is the main build risk — de-risk in Phase 0.
- **CORS on external streaming servers.** The `.m3u8` + segments are served by a
third-party origin; playback in a static site depends on their CORS. Verify in
Phase 0 (same class of constraint as the storage/edge CORS work).
- **Stale "live" events.** Streams end without always updating `status`; a dead
`.m3u8` must fail gracefully and ideally be filtered. Needs a liveness signal
(recent update time / probe).
- **Relay fan-out.** A live-discovery subscription adds REQ load — respect the
cold-login relay-cap / circuit-breaker invariants (AGENTS.md §17); keep it
deferred and capped like nip71 ingest.
- **Scope creep into #16c.** Keep this strictly watch-only; broadcasting is a
separate flag + plan.
- **High-volume chat.** A busy stream's kind-1311 can arrive fast; the read view
must cap/virtualize the message buffer (drop-oldest) so it can't balloon memory
or jank the UI. Tie into the auto-scroll/pause behavior.
- **`status=planned` (scheduled) streams.** 30311 supports a not-yet-live state.
v1 shows only live + ended (DECISION 4); decide later whether to surface
"upcoming" (with `starts`). Just don't let a `planned` event render as if live.
- **Multiple `streaming` renditions.** `streaming` may appear more than once
(quality variants) or point at a master playlist. Pick one sensibly (hls.js
handles master playlists); confirm shapes in Phase 0.
- **Private/token-gated streams.** Only public HLS is in scope — a stream whose
`.m3u8` requires auth/tokens is treated as "can't play," gracefully.
---
## Sources
- NIP-53 (Live Activities; kind 30311 live event, kind 1311 live chat).
- Reference implementations: zap.stream, shosho.live.
- Existing code: `js/services/nip71IngestService.js` (deferred/throttled ingest
pattern to mirror), `js/services/playbackService.js` (player entry for HLS),
`js/app/feedCoordinator.js`, `components/sidebar.html`, `js/constants.js`
(`FEED_TYPES`), `js/ui/components/VideoCard.js` (contrast for the new LiveCard).
- hls.js (candidate HLS playback dependency, DECISION 3).
- Live watch-UX research (July 2026):
- [Streaming App UX Best Practices: 7 Pillars — Fora Soft](https://www.forasoft.com/blog/article/streaming-app-ux-best-practices) (player+chat layout, live vs VOD, social overlays as retention)
- [Top Live Streaming Platforms 2026 — VdoCipher](https://www.vdocipher.com/blog/live-streaming-platforms/) (latency: HLS/DASH 6–30s, LL-HLS 1–5s, WebRTC <500ms)
- [Twitch mobile viewer update, resizable chat overlay + PiP (Stream-Rise)](https://stream-rise.com/blog/twitch-mobile-broadcasting) (mobile chat-below / draggable overlay pattern)
+458
View File
@@ -0,0 +1,458 @@
# BitVid Live — PUBLISH / "Go Live" — Dev Plan
TODO ref: **#16c** in `todo/TODO_2026-06-20_pre-launch.md` (this doc expands the
stub). Companion to **`docs/live-ingest-plan.md`** (#16, watch-only) — the *watch*
side (Live tab, player, chat, zaps) is built there and **reused** here; this doc
adds the *broadcast* side.
Status: **FUTURE / exploratory — all six decisions LOCKED** (see Core Decision's
blanket rule: bitvid hosts nothing; self-host or external only). Sequenced
**after** #16 (live ingest) and #16b (shorts). The largest plan, and the only one
touching **(self-hosted, never bitvid-run) media infrastructure**.
---
## Core decision
> **BLANKET RULE (maintainer, non-negotiable): bitvid.network hosts NOTHING but a
> static client — no servers, no media infrastructure, ever.** All media
> receiving/routing is **self-hosted by the creator** (the Bridge run headless — a
> single self-hosted binary, see `docs/bitvid-bridge-plan.md`) or handled by an
> **external provider** (zap.stream/YouTube/Twitch). There is no bitvid-operated
> Media Node and no managed publishing — those options are off the table.
Build **BitVid Live** as a **Nostr-native live-stream control layer**, not a
video-hosting backend. BitVid the client stays exactly what it is — a static site
that signs client-side, holds no keys, runs no server. The one thing a static
browser app genuinely *cannot* do (receive + route live media) is isolated into a
**separate, self-hosted-or-external** service (never bitvid-operated). Split:
```
BitVid static client = identity, metadata, NIP-53 publishing, discovery, chat,
zaps, player UI, archive→VOD publishing
Media Node / Bridge = RTMP/SRT/WHIP ingest, HLS/WebRTC output, recording,
(self-hosted OR restreaming, status API
external provider)
```
This keeps BitVid's architecture intact while adding broadcast. Crucially,
**most of these modes need no bitvid infrastructure at all** — see the three-tier
spectrum next.
---
## Provider options — three tiers (bitvid hosts none of them)
The creator chooses who receives their media; bitvid only ever **publishes the
resulting `streaming` URL and plays it**. A VPS is only the *most sovereign* tier,
not a requirement — most creators never run a server.
| Tier | Setup / cost | Playback in bitvid | Trade-off | bitvid work |
|------|--------------|--------------------|-----------|-------------|
| **1. Platform embed** — StreamYard/OBS → YouTube/Twitch | free, **zero infra** | their **iframe** embed | their rules/ads; no native playback, no P2P/WebTorrent | external-embed adapter |
| **2. Serverless RTMP→HLS API** — Livepeer / Mux / Cloudflare Stream | ~free–cheap, **no server** | **native HLS** (+ P2P-assist possible) | small cost at scale; a 3rd-party account | native HLS (from #16) |
| **3. Self-hosted node** — the Bridge run headless on a VPS | most effort/cost | native HLS | full **sovereignty + WebTorrent VOD + restream** | native HLS + Bridge |
- **Recommended default "no-VPS but native": Tier 2, Livepeer** — decentralized,
crypto-native, cheapest-aligned; the creator points OBS/StreamYard at Livepeer's
RTMP ingest and pastes the HLS URL into bitvid. [Mux](https://www.mux.com/live)
(free delivery allotment; live needs a paid quality tier) and
[Cloudflare Stream](https://developers.cloudflare.com/stream/pricing/) (free
ingest/encoding, cheap delivery) are alternatives.
- **StreamYard is a studio, not a CDN** — it broadcasts *out* to
YouTube/Twitch/custom-RTMP (custom RTMP is paid), so with bitvid it's a Tier-1
encoder feeding YouTube/Twitch, or a Tier-2/3 encoder feeding a serverless
API / your node.
- **The same NIP-53 event + player serve all three tiers** — the only per-tier
code is the playback adapter (external embed vs native HLS). So supporting the
spectrum is cheap, and the VPS/Bridge is the *advanced* tier, not the default.
---
## Relationship to the other two plans
- **#16 (live-ingest) is a hard dependency.** It builds the NIP-53 **parsers**
(30311/1311), the **Live tab**, `LiveCard`, `LivePlayer`, `LiveChatPanel`, HLS
playback (hls.js), and host zaps. This plan **reuses** all of that to *watch*
streams and adds the **builders** + Studio + Media Node/Bridge to *create* them.
- **Shared Phase 0 schema.** #16 adds `parseLiveStreamEvent`/`parseLiveChatMessageEvent`;
this plan adds `buildLiveStreamEvent`/`buildLiveChatMessageEvent`/
`updateLiveStreamStatusEvent`. Do them in `js/nostrEventSchemas.js` together so
the catalogue stays consistent — coordinate so they aren't duplicated.
- **My Videos "Live" sub-tab** (noted in the shorts plan) belongs to *this*
feature — managing your own streams only makes sense once you can publish.
---
## Why BitVid Live — advantages over the incumbents
Lean into what only bitvid can offer; these are the reasons a creator would pick
BitVid Live over zap.stream/Twitch/YouTube.
- **P2P bandwidth offload (WebTorrent-flavored live).** Classic WebTorrent can't
carry a live stream (its infohash needs the *complete* file), but **P2P-assisted
HLS** can: hls.js + [P2P Media Loader](https://github.com/novage/p2p-media-loader)
(historically WebTorrent-tracker-based) lets concurrent viewers share HLS
segments over WebRTC, so each extra viewer helps *serve* the stream. This
directly cuts the Media Node's egress — the biggest cost of running live infra —
and scales *better* the more popular a stream gets. (Enable when concurrency
warrants; see the ingest plan.)
- **Recordings become full WebTorrent VODs.** The moment a stream ends, its
recording is an ordinary bitvid video: complete file → infohash → **seed +
webseed**, playable P2P + URL like everything else. Twitch/YouTube VODs are
origin-only; bitvid VODs are P2P-distributable and self-hostable.
- **Nostr-native = portable + no lock-in.** The stream is a signed NIP-53 event on
relays, not a row in a platform DB. It's discoverable by *any* Nostr app, the
creator owns it, and it can't be de-platformed by a single company.
- **Zaps built in.** Instant Lightning value-for-value to the host during the
stream (bitvid's existing NWC/zap system) — no ads, no 50% platform cut, no
payout thresholds.
- **Bring-your-own everything.** Own storage (R2/B2/S3/MinIO) for recordings, own
relays, self-hostable Media Node, and an **external-provider-only mode that needs
zero bitvid infra**. No mandatory middleman.
- **One canonical stream, many mirrors.** Restream to YouTube/Twitch/zap.stream
simultaneously while bitvid stays the sovereign home — reach without lock-in.
- **Archive is a first-class video, not a throwaway.** VODs flow into the same
feeds, search, moderation, comments, and zaps as every other bitvid video (the
`s`-tag/info.json/30078 model) — no separate "past broadcasts" ghetto.
- **Self-sovereign identity.** The creator signs; no BitVid account, no bridge
ever holds their key.
---
## Decisions needed
> **DECISION 1 — Media hosting model. ✅ LOCKED by the blanket rule: a three-tier
> provider spectrum — bitvid hosts NONE of them.** The creator picks who receives
> their media; bitvid just publishes the resulting `streaming` URL. **A VPS is only
> the *most sovereign* tier, not a requirement.** See "Provider options" below:
> 1. **Platform embed** (StreamYard/OBS → YouTube/Twitch) — free, zero infra;
> bitvid embeds their player. **Ships first (Phase 1).**
> 2. **Serverless RTMP→HLS API** (Livepeer / Mux / Cloudflare Stream) — no server
> to run; the creator gets a real HLS URL bitvid plays **natively**. The
> recommended "no-VPS but native" default. **Livepeer** is the most aligned.
> 3. **Self-hosted node** (the Bridge run headless on a VPS) — full sovereignty +
> WebTorrent VOD + restream control; most effort. The advanced tier.
>
> The managed-BitVid-node option is **removed**. bitvid's job (publish the event,
> play the `streaming` URL) is **identical across all three** — the only code
> difference is a per-tier playback adapter (external embed vs native HLS).
> **DECISION 2 — Signing during a live stream. ✅ LOCKED: A for MVP, B (NIP-46)
> later; D removed by the blanket rule.** The node must NOT hold the creator's key.
> **(A) BitVid tab/app stays open** and signs status=live/ended (matches the
> Bridge's embedded-webview design) — MVP. **(B) NIP-46 remote signer** for a
> hands-off experience — later. **(C)** a *self-hosted* node may also publish
> provider-status under its own pubkey while the canonical 30311 stays user-signed
> — available to self-hosters. **(D) managed publishing account is off the table**
> (blanket rule).
> **DECISION 3 — Transcode. ✅ LOCKED: pass-through only (`-c copy`) in v1.**
> Require H.264/AAC + ~2s keyframes in; no transcode ladders. Transcoding is a
> heavy, self-hoster-only opt-in much later, if ever.
> **DECISION 4 — WebRTC low-latency. ✅ LOCKED: HLS first, WebRTC later.** HLS
> (~6–30s) works everywhere via #16's hls.js; WebRTC (<500ms) is a later phase.
> **DECISION 5 — Local Bridge in MVP. ✅ LOCKED: not required for the external-
> provider MVP; it IS the self-hosting path (DECISION 1b).** With the blanket rule,
> self-hosting a Media Node **is** running the Bridge headless, so the Bridge isn't
> an optional extra for self-hosters — it's *the* mechanism. The external-provider
> MVP (Phase 1) still needs neither. Full plan: `docs/bitvid-bridge-plan.md`.
> **DECISION 6 — Flag naming. ✅ LOCKED: keep the specific split.**
> `FEATURE_LIVE_INGEST` / `_PUBLISH` / `_BRIDGE` / `_RESTREAM` (+ `_CHAT_POST` from
> #16). No umbrella `FEATURE_LIVE`. `FEATURE_LIVE_PUBLISH` implies ingest is on.
---
## Config flags (off = no trace)
Every "Go Live" surface is instance-config gated and defaults **off**, threaded
`config/instance-config.js` → `js/config.js` → `js/constants.js` like
`FEATURE_NIP71_INGEST`. An operator can ship the code and have publishing be
completely invisible until they opt in.
- `FEATURE_LIVE_INGEST` — watch side (from #16; a dependency of publish).
- `FEATURE_LIVE_PUBLISH` — the Studio / Go-Live publishing UI (the master
publish switch).
- `FEATURE_LIVE_BRIDGE` — Media Node / Bridge session API integration.
- `FEATURE_LIVE_RESTREAM` — external restream output management.
**Independent of watching.** These are separate from `FEATURE_LIVE_INGEST`, so an
instance can enable *watching* live streams without enabling *broadcasting* (or
vice-versa). `FEATURE_LIVE_PUBLISH` implies ingest is on (you watch your own
stream); it does not force ingest on for everyone if the operator wants
watch-only.
When `FEATURE_LIVE_PUBLISH` is off, **all** of the following are absent — no tab,
no button, no trace (as if never added):
1. **"Go Live" / Studio entry points** — no Studio launch anywhere (sidebar,
upload modal, profile).
2. **My Videos → Live sub-tab** — not rendered (shorts-plan sub-tab treatment).
3. **Live-event builders** — `buildLiveStreamEvent` / `updateLiveStreamStatusEvent`
are not wired into any UI (the schema definitions can exist dormant; nothing
*invokes* them).
4. **Bridge/session UI** — pairing, ingest settings, health dashboard (also gated
on `FEATURE_LIVE_BRIDGE`).
5. **Restream UI** — output manager (also gated on `FEATURE_LIVE_RESTREAM`).
6. **Archive→VOD "publish recording"** live-origin flow (a normal video upload is
unaffected).
7. **Routes** — any `#view=live-studio` (or equivalent) falls back to default.
8. **Config surface** — the flags are the only footprint.
Bridge and restream have their own sub-flags so an operator can allow, e.g.,
publishing to their own Media Node **without** exposing external restreams.
**Who may go live (operator control).** Beyond the on/off flags, publishing on an
instance should respect the existing **whitelist/WoT** model — i.e. an operator
can allow only whitelisted creators to broadcast on their instance (reusing
`accessControl`), the same way the feeds are gated. Decide whether go-live is
whitelist-gated by default (recommended: yes on whitelist-mode instances) so a
public instance isn't an open ingest relay.
---
## Event model (NIP-53 — shared with #16)
Canonical live address = `30311:<creator-pubkey>:<d-tag>` (an addressable
replaceable event; dedupe by pubkey + `d`, exactly like bitvid treats video
`d`-tags). Key tags: `d`, `title`, `summary`, `image`, `streaming` (HLS/WebRTC
playback URL — the canonical first entry), `recording` (VOD when ended), `starts`,
`ends`, `status` (`planned`/`live`/`ended`), `relays`, `t` (hashtags), `p` (host +
participants), `service`.
Live chat = **kind 1311**, anchored to the activity via
`['a', '30311:<pubkey>:<d>', '<relay>', 'root']` — so bitvid live chat is just
Nostr, reusing #16's chat pipeline.
Schema work (in `js/nostrEventSchemas.js`, coordinated with #16):
- `NOTE_TYPES.LIVE_STREAM` (30311), `NOTE_TYPES.LIVE_CHAT_MESSAGE` (1311).
- Builders: `buildLiveStreamEvent`, `updateLiveStreamStatusEvent`,
`buildLiveChatMessageEvent`. Parsers: from #16.
- Helper: `getLiveAddress(pubkey, d)` → the canonical `a` coordinate.
**Safety invariant:** the public 30311 event carries **only** the playback URL and
public metadata. **Never** publish RTMP/SRT ingest URLs or stream keys into Nostr.
---
## Auth & key custody (the non-negotiables)
1. **BitVid signs; the Bridge routes.** The Media Node/Bridge never receives the
creator's Nostr private key. It controls *media sessions*, not *identity*.
2. **Bridge auth via NIP-98 HTTP-Auth (kind 27235).** bitvid already has this
builder (`buildHttpAuthEvent`, `js/nostrEventSchemas.js`). The client signs a
27235 event ("I, pubkey X, authorize creating session Y at URL Z"); the node
verifies the signature, mints a stream key, returns ingest/playback URLs.
3. **Stream keys stay out of Nostr** — held node-side (encrypted config / secret
store) or, in local-Bridge mode, in the OS keychain. Public event gets the HLS
URL only.
---
## Architecture — four pieces
- **BitVid Live Client** *(static app; reuses #16)* — browse/watch, render NIP-53,
play HLS/WebRTC/external, chat + zaps, planned/live/ended state, and the
**archive→VOD** convert flow.
- **BitVid Live Studio** *(static app; new, this plan)* — create live-event
metadata, publish planned→live→ended NIP-53 updates, show OBS/encoder ingest
settings, connect to Bridge/Node, monitor health, manage restream outputs,
publish the recording.
- **Media Node** *(server-side; **self-hosted by the creator**, never bitvid-run)*
— RTMP/SRT/WHIP ingest → HLS/WebRTC out, recording, S3-compatible upload, status
API, optional restream. **Built on
[MediaMTX](https://mediamtx.org/docs/kickoff/introduction)** — not from scratch.
Per DECISION 7 it's the **same binary as the Bridge, run headless**.
- **BitVid Bridge** *(the self-hosting vehicle — desktop app or headless node)* —
local RTMP/SRT receiver for hardware encoders, one-input→many-outputs restream,
local recording, archive upload, OBS-WebSocket control. **This is how a creator
self-hosts** (DECISION 1b) and the seed of a future "BitVid Box".
New client modules (matching repo style): `js/services/liveEventService.js`,
`liveChatService.js`, `liveBridgeClient.js`, `livePlaybackService.js` (or extend
#16's), `liveArchiveService.js`; UI `js/ui/views/LiveStudioView.js`,
`js/ui/components/{LiveBridgeSetup,LiveRestreamOutputs}.js` (LiveCard/LivePlayer/
LiveChatPanel come from #16).
---
## Media Node & Bridge API
One stable path per session: `/live/<creator-pubkey>/<d-tag>`. Ingest via RTMP /
SRT / WHIP (key/token kept secret); playback via HLS (`/index.m3u8`) / WebRTC. The
client publishes only the playback URL into the 30311 `streaming` tag.
Bridge API surface (session lifecycle + SSE/WebSocket status):
`POST /sessions`, `GET /sessions/:id`, `.../start`, `.../end`, `.../outputs*`,
`GET /sessions/:id/status|manifest`, `GET /events` (SSE stream of
`input_connected|input_live|input_lost|recording_finalized|upload_complete|…`).
The client listens and decides when to sign+publish NIP-53 updates (DECISION 2).
---
## Outgoing streams — three distinct concepts
- **A. Primary BitVid output** — OBS/encoder → Media Node → HLS/WebRTC → the URL
that goes in the 30311 `streaming` tag.
- **B. External restream outputs** *(`FEATURE_LIVE_RESTREAM`)* — the node forwards
the same encoded stream to YouTube/Twitch/zap.stream/custom RTMP/SRT.
**Stream-copy only** in v1 (`-c copy`, no transcode — MediaMTX `runOnReady` +
FFmpeg forward). Output keys stay node-private; public mirrors optionally added
as `['r', <public-watch-url>]`.
- **C. Archive / VOD output** — on end, MediaMTX records fMP4/TS → finalize →
upload to R2/B2/S3/MinIO → produce `recording.mp4` + `.info.json` (+ optional
thumbnail/torrent). This maps **directly onto bitvid's existing storage-pointer
model**: the `['s', '<provider>:<prefix>']` tag + `js/utils/storagePointer.js`
already derives `<prefix>.info.json`, so the archived stream becomes an ordinary
bitvid video with zero new playback/metadata code.
---
## Archive → VOD publishing (reuses everything)
When the Bridge returns a recording manifest (url, thumbnail, duration, sha256,
infoJson, optional torrent/magnet/webseed), the Studio shows **"Publish recording
as a BitVid video."** On click bitvid publishes:
1. **Updated 30311** — `status=ended` + `recording=<url>`.
2. **Canonical bitvid video** — **kind 30078** (Content Schema v3, the same event
the upload modal produces) with the hosted URL + `s` pointer.
3. **Optional NIP-71 mirror** — bitvid's addressable **34235** (normal) via the
existing mirror service, so other apps see the VOD (per the nip71 plan; note
bitvid mirrors to 34235/34236, not raw kind 21).
This is a specialized upload-modal flow, not a new publishing system.
---
## Playback strategy
Live: **HLS first** (works everywhere via #16's hls.js), then WebRTC later
(DECISION 4), then external-embed fallback. Mirror bitvid's URL-first ethos:
**primary live URL → external mirror → ended recording (VOD).** Optional
**P2P-assisted HLS** (P2P Media Loader) offloads Media Node egress once a stream
has enough concurrent viewers — see the ingest plan + Advantages above. The ended
**recording gets full classic WebTorrent** (seed + webseed), like any bitvid video.
---
## Build order & the static-first principle
> **PRIORITY (maintainer): pour effort into the static bitvid client before
> branching into any separate app.** Everything through the Go-Live MVP below lives
> **entirely in the static client** — no Bridge, no VPS, no bitvid infra. The
> Bridge (a second application) is deliberately deferred until this is shipped and
> maximally developed. See `docs/bitvid-bridge-plan.md`.
Recommended sequence across the live/short work:
```
1. Shorts (#16b) ← smallest, ready to build, unrelated to live
2. Live ingest (#16) ← the WATCH foundation (Live tab, player, hls.js,
chat, zaps) — a hard dependency of publishing
3. Go-Live MVP (#16c P0–P1) ← Tiers 1 & 2 (external embed + serverless HLS);
CLIENT-SIDE ONLY, no Bridge / no VPS / no infra
── ship it; validate that people actually want live ──
4. (only if warranted) the Bridge app ← Tier-3 sovereignty/power features;
its own repo/toolchain/code-signing — a later track
```
The Go-Live MVP (steps 1–3) is the "as much static bitvid as possible" target: a
real, working live experience where the creator brings their own provider
(StreamYard→YouTube, or OBS→Livepeer/Mux) and bitvid does the Nostr-native part —
publish, discover, watch, chat, zap, archive→VOD. Only after that proves out does
a second app (the Bridge) earn the investment.
---
## Phases
- **Phase 0 — Flags + schema (small).** `FEATURE_LIVE_PUBLISH/BRIDGE/RESTREAM`;
30311/1311 **builders** + `updateLiveStreamStatusEvent` + `getLiveAddress`
(coordinate with #16's parsers); `docs/live.md`; schema-doc update; unit tests.
- **Phase 1 — Provider-agnostic publishing (small; NO infra — Tiers 1 & 2).**
"Create Live Stream" modal: title/summary/thumbnail/tags/starts + a paste-in
`streaming` URL / platform link; publish planned→live→ended; chat + zaps against
the live address (reusing #16). Playback adapter picks **external embed**
(Tier 1: YouTube/Twitch) or **native HLS** (Tier 2: Livepeer/Mux/Cloudflare).
*This is BitVid Live v0, needs zero servers, and covers the recommended no-VPS
path (Tier 2 / Livepeer).*
- **Phase 2 — Bridge API client + mock backend (medium).** `liveBridgeClient`,
Pair-Bridge UI, mocked `/sessions`, setup wizard, status dashboard,
recording-ready flow — builds the client contract before real infra exists.
- **Phase 3 — Self-hosted Media Node MVP (large, server-side).** A **reference
node the creator runs themselves** (never bitvid-operated) around MediaMTX:
session API, NIP-98-verified auth, token'd RTMP ingest, HLS playback, start/stop
detection, status endpoint. OBS → the creator's node → bitvid watches. No keys in
Nostr. Per DECISION 7 (Bridge plan) this is the **same binary as the Bridge, run
headless** — so Phase 3 and Phase 7 share code. *This is BitVid Live v1.*
- **Phase 4 — Recording → archive → VOD (medium).** MediaMTX recording → finalize
→ S3 upload → manifest → the archive→VOD publish flow above.
- **Phase 5 — Restream outputs (medium; `FEATURE_LIVE_RESTREAM`).** Output manager,
provider presets (YouTube/Twitch/zap.stream/custom), copy-only forward,
retry/backoff, per-output status. One canonical `streaming` URL; mirrors are
optional `r` links.
- **Phase 6 — OBS Companion (medium).** Start/stop OBS + read bitrate/dropped
frames via OBS-WebSocket (built into OBS 28+). Prefer routing through the local
Bridge to avoid browser↔localhost/mixed-content pain.
- **Phase 7 — Local Bridge app (large; `FEATURE_LIVE_BRIDGE`).** Packaged desktop
helper: local RTMP/SRT receiver, forward to Node + external, local record +
archive upload, embedded Studio webview (signs) + optional localhost API.
*BitVid Live v2 / "BitVid Box" seed.* **Full plan: `docs/bitvid-bridge-plan.md`**
(cross-platform app; note DECISION 7 there — the Bridge and this Media Node can
share one codebase running headless vs with-UI).
- **Phase 8 — WebRTC low-latency (later, DECISION 4).**
---
## Engineering rules (invariants)
1. **BitVid signs; the Bridge routes** — the node never owns the creator's Nostr
identity.
2. **Public playback URLs are safe; ingest URLs are secret** — publish the
`.m3u8`, never `rtmp://…?key=SECRET`.
3. **Pass-through first, transcode later** — copy the H.264/AAC stream in v1.
4. **One canonical stream, many optional mirrors** — the 30311 has one `streaming`
URL; external platforms are outputs, not the source of truth (unless the creator
picks an external provider as primary in Phase-1 mode).
5. **Archived live streams become normal BitVid videos** — no separate archive
system; reuse the `s`-tag / info.json / 30078 model.
---
## Risks / watch-items
- **bitvid stays static; infra is the creator's.** The Media Node/Bridge is real
server infra, but per the **blanket rule it's never bitvid-operated** — it's
self-hosted by the creator or replaced by an external provider. So the ops/cost
burden lives with whoever self-hosts, not bitvid.network. The static client is
unaffected; Phase-1 external mode needs no infra at all.
- **Self-hoster burden (DECISION 1b).** Bandwidth/storage cost, encoder config, and
keeping the node reachable fall on the self-hoster. Good docs + a turnkey
headless Bridge (one binary) are the mitigation.
- **Signing during long streams (DECISION 2).** Tab-open (A) is fragile for long
broadcasts; NIP-53 lets clients treat a live event with no update in ~1h as
ended, so plan a heartbeat and a graceful "stale → ended" story.
- **CORS / cross-origin HLS** — segments served by the (self-hosted) node's origin
must allow the bitvid origin (same class as the storage/edge CORS work).
- **Encoder compatibility** — pass-through requires H.264/AAC + sane keyframes;
document encoder settings; reject or warn on incompatible input.
- **Scope discipline** — publishing is genuinely large; ship Phase 1 (no infra)
first and treat the Media Node as a separate service milestone.
---
## Sources
- **NIP-53** (Live Activities; kind 30311, live chat 1311):
https://nips.nostr.com/53
- **MediaMTX** (ingest/playback/record/forward/control API):
https://mediamtx.org/docs/kickoff/introduction ·
[forward](https://mediamtx.org/docs/usage/forward) ·
[record](https://mediamtx.org/docs/usage/record)
- **OBS**: [SRT guide](https://obsproject.com/kb/srt-protocol-streaming-guide) ·
[WebSocket remote control](https://obsproject.com/kb/remote-control-guide)
- Existing bitvid code this reuses: `js/nostrEventSchemas.js` (HTTP_AUTH/27235,
30078 video), `js/utils/storagePointer.js` (`s` tag → `.info.json`),
`docs/live-ingest-plan.md` (#16 watch side), `docs/nip71-migration-plan.md`
(mirror to 34235/34236), `js/constants.js` (`FEATURE_*` flags).
+367
View File
@@ -0,0 +1,367 @@
# Shorts (short-form vertical video) — Dev Plan
TODO ref: **#16b** in `todo/TODO_2026-06-20_pre-launch.md`.
Status: **DECISIONS LOCKED — ready to build** (D1–D5, D7, D8 locked; D6 is a
build-time implementation choice). Not started.
Short-form vertical video is **NIP-71 kind 22** (the short counterpart to kind 21
normal video), plus the addressable short kind **34236**. It is a *different*
feature from live streams (#16, NIP-53) — do not conflate them.
The good news: **bitvid already ingests kind 22.** The inbound NIP-71 adapter
(`js/nostr/nip71IngestAdapter.js`, `NIP71_KINDS = {21, 22, 34235, 34236}`) parses
short-form notes today and they already flow into the main feed. So this feature
is **not new ingest code** — it is (a) a single `isShort` discriminator (native
marker / foreign kind), (b) a dedicated **Shorts sidebar tab** with a vertical
UI, (c) a feed source that lists only shorts, (d) **exclusion of shorts from the
main/discovery feeds** (DECISION 3), and (e) a **publish-side content-type
selector** + native short marker (DECISION 7) so bitvid creators can post shorts.
Everything ships behind a config flag that leaves **no trace** when off.
---
## Decisions needed
> **DECISION 1 — Discovery scope. ✅ LOCKED: Option A (same whitelist scope).**
> Shorts pulls from the same whitelisted/WoT authors as the main feed and reuses
> the existing render-time moderation filter — no new moderation surface, no extra
> relay fan-out. Rationale (maintainer): the existing moderation + ingest system
> is already good; keep Shorts consistent with it. A broader/Explore-style scope
> can be added later if desired.
> **DECISION 2 — Moderation / NSFW. ✅ LOCKED: Option A (inherit, no special
> handling).** Shorts pass through the same render-time `filterVideos` path as
> every grid (event blacklist / author blacklist / WoT mute / NSFW / private).
> No short-specific moderation. Note: the NSFW gate already *excludes* NSFW from
> users who haven't opted in, so the immersive autoplay feed carries no extra
> NSFW-exposure risk — opted-in users chose to see it, everyone else never
> receives it. Rationale (maintainer): no reason to treat shorts differently.
> **One agreed safeguard:** even for opted-in users, an **NSFW short does NOT
> autoplay** in the immersive feed — it stays blurred/paused with a tap-to-play
> affordance (autoplay resumes on the next non-NSFW short). This is the only
> short-specific behavior; all *filtering* still inherits the shared pipeline.
> **DECISION 3 — Do shorts also appear in the main feed? ✅ LOCKED: Option B
> (Shorts tab ONLY; excluded from the main/discovery feeds).** Rationale
> (maintainer): shorts should not dilute the main feed at all — the Shorts tab is
> their only home.
> **Build implication:** add an `isShort` **exclusion** to the general feeds —
> Recent, For You, Explore, Trending, Kids — so kind-22/short videos never render
> there (they currently do via NIP-71 ingest). The Shorts feed is the inverse
> filter (`isShort` only). This touches each feed source/sorter, so verify no
> feed regressions (couples with #20/#21 feed-identity work).
> **Sub-point (recommend, not blocking):** a creator's shorts SHOULD still appear
> on their **Channel profile** page (it's their full catalogue) and in
> **Subscriptions** is TBD — default: exclude from Subscriptions too (it's a
> discovery feed), keep on Channel profile. Confirm during Phase 1.
> **DECISION 4 — What counts as a "short"? ✅ LOCKED: Option A (kind is the
> definer).** A video is a short iff its event kind is **22** or **34236**;
> kind 21/34235 = regular. No aspect-ratio heuristic — the publisher's declared
> kind decides. Rationale (maintainer): shorts are typically vertical, but the
> protocol kind is the source of truth; a vertical video mis-published as regular
> just renders in the 16:9 card (thumbnail center-cropped via `object-fit:cover`;
> the player letterboxes it via `object-fit:contain`, never distorts) and stays in
> the main feed — the publisher's choice. Aspect-ratio fallback can be added later
> if too many real shorts leak in as kind-21.
> **DECISION 5 — UI ambition for v1. ✅ LOCKED: Option C (phased).** Phase 1 ships
> the grid + portrait card + the main-feed exclusion (DECISION 3) so shorts are
> corralled into their own tab immediately (the maintainer's priority); Phase 2
> adds the full immersive autoplay/swipe feed later. Front-loads "shorts out of
> the main feed," defers the heavier immersive build.
> **DECISION 6 — Card design.** Shorts need a **new portrait (9:16) card** — the
> existing `js/ui/components/VideoCard.js` hardcodes a **16:9 landscape** media
> ratio (`ratio-16-9`), so it can't render shorts correctly as-is. Two options:
> (a) a **new `ShortCard` component** (portrait thumbnail, compact meta, its own
> `data-testid`); or (b) a **portrait "mode"/variant of `VideoCard`** driven by a
> flag (e.g. `variant: "short"` swapping the ratio class + layout).
> *Recommendation: (b) a portrait variant of `VideoCard` if the internals are
> clean enough to parameterize the ratio + layout — it inherits thumbnail
> binding, moderation badges, engagement controls, and the ⋯ menu for free;
> fall back to (a) a dedicated `ShortCard` only if forcing a variant makes
> `VideoCard` unwieldy or pushes it past its size cap.* Either way this is a
> **first-class deliverable**, not an afterthought — the grid can't ship without it.
> **DECISION 7 — Publish side: content-type selector + native short marker.
> ✅ AGREED (maintainer-proposed).** Add an **upload-modal content-type selector**
> (Regular video ⟷ Short) with **visual + text helpers** so creators pick the
> right type and understand placement — e.g. landscape/portrait icons + copy like
> *"Shorts appear only in the Shorts tab, not the main feed."* This is not just
> UX: bitvid's canonical event is **kind 30078** (not 21/22), and the schema has
> **no short marker today** (short is only inferred from dimensions at mirror
> time). So this decision also adds an explicit **`isShort` (or `format:
> "short"`) field to the v3 content schema**, set by the selector, which (a) lets
> the Shorts feed filter + main-feed exclusion catch bitvid-native shorts, and
> (b) forces the NIP-71 mirror to kind **34236**. Detection becomes: a video is a
> short iff `kind ∈ {22,34236}` (foreign/ingested) **OR** `isShort === true`
> (bitvid-native). *Open sub-choices for build time: default selection (recommend
> "Regular"), whether to auto-suggest "Short" when the uploaded file is portrait,
> and exact copy/icons.*
> **DECISION 8 — Native short representation. ⏳ LEANING: marker on kind 30078
> (both options mirror to 34236).** Hard requirement (maintainer): a native short
> MUST keep bitvid's WebTorrent advantage AND mirror to the standard NIP-71 short
> kind via the existing mirror system. Both viable options satisfy that — they
> produce the *same external result* (a WebTorrent-rich native event + a standard
> **34236** mirror carrying the magnet via `imeta`):
>
> - **Option 1 (recommended) — kind 30078 + `isShort` marker.** Native short = a
> normal bitvid video flagged short; the *existing* mirror emits 34236. No new
> kind. Reuses 100% of the publish/edit/delete/moderation/view-count/zap/mirror
> pipeline (all keyed on 30078). Least code, least risk.
> - **Option 2 — a distinct bitvid-short kind that mirrors to 34236.** A separate
> kind number for rich native shorts + the mirror to 34236 for interop.
> *Trade-off:* the external result is identical to Option 1, but the distinct
> *native* kind is non-standard (only the 34236 mirror is recognized by other
> clients) and it forks every pipeline that's currently keyed on 30078 — i.e.
> more work for the same outcome. A new kind buys a distinct native kind number;
> it does **not** buy WebTorrent or interop that Option 1 lacks.
>
> Net: Option 1 already delivers "WebTorrent + mirror to the standard kind"
> without minting a kind. Only pick Option 2 if a distinct native kind number is
> itself a requirement.
>
> **✅ LOCKED: Option 1 (marker on kind 30078), provided shorts are reliably
> distinguishable.** They are: a single `isShort` boolean discriminates them
> everywhere — set from the `isShort`/`format:"short"` content field for native
> videos and from `kind ∈ {22,34236}` for ingested ones (see Field mapping). The
> Shorts feed filters `isShort === true`; the main feeds exclude it.
---
## Config flag (off = no trace)
Add `FEATURE_SHORTS` to `config/instance-config.js` (default `false`), threaded
through `js/config.js` → `js/constants.js` exactly like `FEATURE_NIP71_INGEST`.
When the flag is off, **all** of the following must be absent (the "as if it were
never added" requirement):
1. **Sidebar tab** — the Shorts link is not rendered in `components/sidebar.html`
(not merely `hidden`).
2. **Feed registration** — `FEED_TYPES.SHORTS` is not registered in
`js/app/feedCoordinator.js` (no pipeline, no sorter).
3. **Subscription/source** — no shorts-specific relay subscription is created.
4. **Route** — `#view=shorts` falls back to the default view (a stale bookmark
can't resurrect the tab).
5. **Publish UI** — the upload-modal content-type selector (DECISION 7) is not
rendered; uploads are plain regular videos exactly as today.
6. **Management UI** — no Shorts sub-tab in profile → My Videos (see below).
7. **Config surface** — the flag is the only footprint.
---
## Publish & management UI (flag-gated)
Both the publish and management surfaces must obey "off = no trace" (points 5–6
above), and reuse patterns already in the app.
- **Upload modal — content-type selector (DECISION 7).** Regular ⟷ Short, with
visual + text placement helpers ("Shorts appear only in the Shorts tab").
Rendered only when `FEATURE_SHORTS` is on; otherwise uploads are plain regular
videos exactly as today.
- **Profile → My Videos — sub-tabs.** Reuse the **admin-pane sub-tab toolbar
pattern** just shipped (`ProfileAdminController` sub-tabs) to split My Videos
into **Videos / Shorts / Live** sub-tabs for easier content management. Each
sub-tab appears **only when its feature flag is on**:
- `FEATURE_SHORTS` off → no Shorts sub-tab.
- Live sub-tab is tied to **publishing** live (**#16c** / `FEATURE_LIVE_PUBLISH`),
NOT ingest (#16) — with ingest-only there is nothing of the user's to manage,
so no Live sub-tab.
- **All flags off → no sub-tab bar at all**, just the plain My Videos list
exactly as today (true "no trace").
- Videos sub-tab lists regular bitvid/NIP-71 videos (excludes `isShort`);
Shorts sub-tab lists the user's `isShort` videos.
## Field mapping — detecting a "short"
Per DECISION 4 + 7 (locked), `isShort` is derived from **kind for foreign/ingested
videos** and from an **explicit marker for bitvid-native videos** (kind 30078):
| Signal | Applies to | Meaning |
|--------|-----------|---------|
| `kind === 22` | ingested foreign | NIP-71 regular short |
| `kind === 34236` | ingested foreign | NIP-71 **addressable** short |
| `isShort` / `format:"short"` in kind-30078 content | **bitvid-native** | set by the upload-modal content-type selector (DECISION 7) |
| `imeta dim` height > width | (future fallback) | portrait aspect; **not** used in v1 (DECISION 4) — kept only as a possible later fallback |
Plan: surface a single `isShort` boolean on the bitvid video object —
`nip71IngestAdapter.js` sets it from the source kind (it already tracks kind);
the native publish path sets it from the new content-schema field. The Shorts
feed filters `isShort === true`; the main feeds exclude `isShort === true`
(DECISION 3). No re-fetching needed.
---
## Architecture
Reuses the existing feed engine + ingest; new pieces are thin.
- **Flag:** `FEATURE_SHORTS` (instance-config → config → constants).
- **Feed type:** add `SHORTS: "shorts"` to `FEED_TYPES` (`js/constants.js`).
- **Feed source/registration:** register `FEED_TYPES.SHORTS` in
`js/app/feedCoordinator.js`, sourced from the already-populated active-video
cache filtered to `isShort` (per DECISION 1, scoped to the same authors as the
main feed). A dedicated sorter can be chronological or reuse the For-You scorer
— TBD, likely chronological for v1.
- **Sidebar tab:** add a Shorts link in `components/sidebar.html`, rendered only
when the flag is on.
- **View:** `views/shorts.html` (a portrait-oriented grid container).
- **Card design (new):** a portrait **9:16** card — either a `variant: "short"`
mode of `js/ui/components/VideoCard.js` (which is currently 16:9 / `ratio-16-9`)
or a dedicated `ShortCard` (DECISION 6). Portrait thumbnail, compact title +
author, reuse of thumbnail binding, moderation badges, engagement + ⋯ menu. New
design tokens if needed (existing `--video-card-*` tokens live in
`css/tokens.css`). A distinct `data-testid` for e2e selectors.
- **Player UI:** v1 opens a short in the existing player modal with a portrait
layout; v2 adds a full-screen vertical swipe/next feed.
- **Diversity:** apply the existing `spreadAuthors` pass (feed sorters) so the
Shorts tab doesn't cluster by creator, consistent with For You / Trending.
---
## Short-form UX best practices (researched July 2026)
Short-form is a **mobile-first** format (~70%+ of social video is watched on
phones, held vertically), but bitvid is a desktop-capable web app, so the Shorts
experience must be **responsive** — the same feed, two presentations. Sources at
the bottom of this doc.
### The immersive feed (both platforms)
- **One short at a time, full-height 9:16**, not a scroll-list of small cards.
The grid (Phase 1) is the *entry point*; tapping/clicking a short drops into an
immersive one-at-a-time feed (Phase 2).
- **Autoplay + loop.** The active short autoplays and loops. Browser autoplay
policy requires **muted autoplay** — so start muted with an obvious unmute, and
persist the user's mute choice across shorts. **Exception (agreed):** NSFW-
flagged shorts do **not** autoplay even for opted-in users — blurred/paused with
tap-to-play (see DECISION 2).
- **Preload the next 2–3.** Buffer upcoming shorts in the background so advancing
is instant. "If your platform shows a loading spinner between videos, you've
already lost the session." (This is the single most-cited make-or-break detail.)
- **Overlaid chrome, safe zones.** Title/author bottom-left, an **action rail**
bottom-right, controls over the frame — keep them inside 9:16 safe zones so they
don't collide with the video edges.
- **Action rail** (bitvid mapping): **Zap** (bitvid's primary engagement, via the
existing NWC/zap system — replaces the generic "like"), **Comment**, **Share**,
and **author avatar + follow/subscribe**. Reuse the existing engagement + ⋯
menu wiring.
- **Captions/muted-friendliness.** A large share of viewing happens muted at some
point; surface captions when a short provides them and make the mute state
obvious. (bitvid can't generate captions — show them when present.)
- **Accessibility:** respect `prefers-reduced-motion` (no auto-advance / disable
scroll-snap animation), full keyboard support, focus management on advance.
### Mobile presentation
- **Full-screen** vertical player; **swipe up/down = next/previous**; tap =
pause/play; the action rail sits on the right thumb-reach edge.
### Desktop presentation
- **Centered vertical player with letterboxed / blurred side panels** — never
stretch a 9:16 short to fill a wide viewport (this is the YouTube-Shorts-on-
desktop pattern).
- **Keyboard navigation is a real differentiator.** YouTube Shorts on desktop has
notoriously weak keyboard support (users install extensions to get it). bitvid
should ship it natively: **↑/↓ = prev/next short**, **Space = pause/play**,
**M = mute**, **←/→ = seek** (or prev/next — pick one and document it), Esc =
exit to grid.
- **Action rail beside the player** (to the right of the letterboxed video) rather
than overlaid, since desktop has the horizontal room.
- Advance via scroll-snap (wheel/trackpad) **and** the arrow keys; a subtle
next/prev affordance for mouse users.
### bitvid-specific notes
- Playback rides the existing URL-first + WebTorrent pipeline (`playbackService`);
shorts are ordinary videos (native kind-30078 + `isShort`, or ingested
21/22/34235/34236), so **no new transport**.
- **WebTorrent + autoplay caution:** an autoplaying scroll feed must NOT spin up a
torrent swarm per short as the user flies past. In the immersive feed, prefer
**URL-first for the active/preloaded shorts** and only fall back to WebTorrent on
demand (e.g. when the URL fails, or the short is dwelt on) — otherwise scrolling
seeds dozens of swarms and thrashes the connection budget. (Risks section.)
- Zaps (not likes) are the headline action — a genuine differentiator vs the
incumbents that already exists in bitvid.
- View counts, watch history, comments, and zaps reuse the existing per-video
machinery — shorts are just videos to those systems.
- Everything still flows through the render-time moderation filter (DECISION 2).
## Phases (each flag-gated from day one)
- **Phase 0 — Detection + flag (small).** Add `FEATURE_SHORTS` (off), the
`isShort` content-schema field (DECISION 7), and `isShort` derivation (foreign =
kind, native = marker) in the ingest adapter + mirror, with unit tests. No UI.
- **Phase 0b — Publish-side selector (small–medium, DECISION 7).** Upload-modal
content-type selector (Regular ⟷ Short) with visual + text placement helpers;
wires the `isShort` marker + forces mirror kind 34236. Lets bitvid creators
actually publish shorts (without it, the Shorts tab only shows ingested foreign
shorts).
- **Phase 1 — Tab + feed + portrait card + main-feed exclusion (medium).**
`FEED_TYPES.SHORTS`, sidebar link, `views/shorts.html`, feed registration
filtered to `isShort`, route gated by flag, and the **new portrait 9:16 card**
(DECISION 6). **Per DECISION 3 (B):** add the `isShort` **exclusion** to the
main/discovery feeds (Recent, For You, Explore, Trending, Kids; likely
Subscriptions) so shorts never appear outside the Shorts tab — with regression
checks on each feed. Opens in the standard player for now.
- **Phase 2 — Immersive vertical feed (medium–large).** The one-at-a-time
responsive feed described in "Short-form UX best practices": muted autoplay +
loop, **preload next 2–3** (no spinner between shorts), overlaid action rail
(Zap/Comment/Share/follow), captions-when-present, `prefers-reduced-motion`
handling. **Mobile:** full-screen + swipe up/down. **Desktop:** centered
letterboxed player + **native keyboard nav** (↑/↓ prev/next, Space, M, Esc) +
side action rail. Grid (Phase 1) remains the entry point.
- **Phase 3 — Polish.** Mute toggle persistence, per-tab empty states, captions
surfacing, seek affordance, and the URL-first-for-autoplay WebTorrent guard.
---
## Moderation, whitelist & NSFW
Per DECISION 2 (locked: inherit): shorts pass through the same render-time
`filterVideos` path (blacklist event ids, author blacklist, WoT mute, NSFW gate,
private exclusion) as every other grid. No new moderation surface. The admin
per-event block list (#25) already applies to any event id, shorts included. The
**one** short-specific behavior is that NSFW-flagged shorts do not autoplay in the
immersive feed (tap-to-play), even for opted-in users — a playback rule, not a
filtering change.
---
## Risks / watch-items
- **Main-feed exclusion touches every feed (DECISION 3).** Adding the `isShort`
exclusion to Recent / For You / Explore / Trending / Kids (and likely
Subscriptions) is the riskiest part — a bug drops the *wrong* videos or lets
shorts leak. Regression-test each feed; couples with the #20/#21 feed-identity
work. A single shared `isShort` predicate (used positively by Shorts, negatively
by the others) keeps it consistent.
- **A native short missing its marker leaks into the main feed.** Since native
detection is the `isShort` content field (not aspect ratio), a bitvid short must
reliably carry the marker on publish AND on edit/re-publish, or it reappears in
the main feed. Cover publish + edit paths; the marker must round-trip through the
mirror/auto-sync.
- **WebTorrent swarm storm in the autoplay feed.** Preloading + autoplaying a
scroll feed over WebTorrent would spin up many swarms at once. Prefer URL-first
for active/preloaded shorts; fall back to WebTorrent on demand (see Architecture
→ bitvid notes). Respect the existing connection budget.
- **Empty tab** — a whitelist-scoped source (DECISION 1) on a small instance may
have few shorts; ship a clear empty state.
- **Search scope (sub-decision, defer to build).** Should shorts appear in
search? They're excluded from the *browse* feeds (DECISION 3) but a user
searching a creator/topic arguably still wants their shorts. Recommend: shorts
remain searchable and open in the Shorts player context; confirm during Phase 1.
---
## Sources
- NIP-71 (video events; kinds 21/22 regular, 34235/34236 addressable).
- Existing code: `js/nostr/nip71IngestAdapter.js`, `js/nostr/nip71Mirror.js`,
`js/app/feedCoordinator.js`, `js/feedEngine/sorters.js`, `components/sidebar.html`,
`js/ui/components/VideoCard.js` (16:9 card to variant/replace).
- `docs/nip71-migration-plan.md` (dual-event model, short=34236 heuristic).
- Short-form UX research (July 2026):
- [Top 10 Features Every Short Video App Needs in 2026 — Primocys](https://primocys.com/blog/top-10-short-video-app-features-2026/) (full-screen player, autoplay/loop, swipe-next, preload next 2–3)
- [Short-Form Video Strategy 2026 — Teleprompter](https://www.teleprompter.com/blog/short-form-video-strategy) (9:16 mobile-first, overlaid chrome/safe zones, captions)
- [Why Vertical Video Is Becoming the Default Format — Oyelabs](https://oyelabs.com/why-vertical-video-is-default-content-format/) (mobile share, vertical engagement)
- [Keyboard shortcuts for YouTube — YouTube Help](https://support.google.com/youtube/answer/7631406) & [YouTube Arrow Keys Fix (Chrome Web Store)](https://chromewebstore.google.com/detail/youtube-arrow-keys-fix/hbnlngeljeofecndhmebgpgpccfnkgjb) (desktop Shorts keyboard-nav gap → do it natively)
+18 -18
View File
@@ -12,6 +12,7 @@ import {
import { SHORT_TIMEOUT_MS } from "./constants.js";
import {
getRegisteredNostrClient,
getActiveSigner,
requestDefaultExtensionPermissions,
} from "./nostrClientRegistry.js";
import { devLogger, userLogger } from "./utils/logger.js";
@@ -574,8 +575,7 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
pubkey,
dTag,
relayUrls: relays,
// Force a full fetch: admin lists are replaceable (see
// tests/nostr/relayBatchFetcherReplaceable.test.mjs).
// Full fetch: admin lists are replaceable (relayBatchFetcherReplaceable.test).
since: 0,
}),
timeoutPromise,
@@ -682,8 +682,7 @@ async function loadCommunityBlacklistEntries() {
return [];
}
// Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm).
// Best-effort: on failure the cached admin state carries over.
// Batch all curator blacklists into ONE REQ (best-effort; cache carries over on fail).
try {
const matched = await fetchBatchedReferenceEvents({
references,
@@ -874,20 +873,21 @@ async function persistNostrState(actorNpub, updates = {}) {
}
ensureNostrReady();
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult.ok) {
throw createError(
"extension-permission-denied",
"The NIP-07 extension must allow signing before updating admin lists.",
permissionResult.error,
);
// Sign with the ACTIVE signer (extension / nsec / NIP-46), not window.nostr.
const signer = getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
throw createError("nostr-signer-missing", "No active signer to update admin lists. Please log in.");
}
const extension = window?.nostr;
if (!extension || typeof extension.signEvent !== "function") {
throw createError(
"nostr-extension-missing",
"A Nostr extension with signEvent support is required."
);
// Only a NIP-07 extension needs an explicit permission prompt before signing.
if (signer.type === "extension" || signer.type === "nip07") {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult.ok) {
throw createError(
"extension-permission-denied",
"The NIP-07 extension must allow signing before updating admin lists.",
permissionResult.error,
);
}
}
const actorHex = decodeNpubToHex(actorNpub);
@@ -950,7 +950,7 @@ async function persistNostrState(actorNpub, updates = {}) {
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
signedEvent = await signer.signEvent(event);
} catch (error) {
throw createError("signature-failed", "Failed to sign admin list event.", error);
}
+288 -20
View File
@@ -89,13 +89,22 @@ import { buildDmRelayListEvent, buildShareEvent } from "./nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
assertAnyRelayAcceptedOrUnconfirmed,
describePublishOutcome,
readRelayPublishSummary,
} from "./nostrPublish.js";
import {
getActiveSigner,
onActiveSignerChanged,
logoutSigner,
} from "./nostrClientRegistry.js";
import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js";
import { rankHashtagsByFrequency } from "./utils/hashtagSuggestions.js";
import {
decideSignerEnsure,
isSignerCapable,
} from "./nostr/ensureSignerDecision.js";
import { showPasswordPrompt } from "./ui/promptDialog.js";
import { queueSignEvent } from "./nostr/signRequestQueue.js";
import {
DEFAULT_NIP07_CORE_METHODS,
@@ -380,6 +389,7 @@ class Application {
isUserLoggedIn: () => this.isUserLoggedIn(),
normalizeHexPubkey: (val) => this.normalizeHexPubkey(val),
getPubkey: () => this.pubkey,
ensureSigner: (options) => this.ensureEncryptionCapableSigner(options),
},
});
@@ -1209,6 +1219,22 @@ class Application {
loginOptions.providerId = savedEntry.providerId;
}
// Restore a kept-unlocked nsec signer from cache BEFORE re-login, so the
// login flow (list decrypt, NWC hydrate) and the later storage auto-unlock
// all inherit it on refresh without a passphrase (TODO #51). For nsec this
// is the only way the signer comes back on reload (the key is passphrase-
// encrypted); safe no-op when nothing is cached or for NIP-07/46.
try {
if (typeof nostrClient.restoreUnlockedSigner === "function") {
await nostrClient.restoreUnlockedSigner(normalizedSaved);
}
} catch (error) {
devLogger.warn(
"[Application] Failed to restore kept-unlocked signer on boot:",
error,
);
}
try {
await this.authService.login(savedPubKey, loginOptions);
} catch (error) {
@@ -2073,6 +2099,199 @@ class Application {
return fallback;
}
/**
* Ensure the ACTIVE signer can encrypt/sign. If it can't because a persisted
* nsec session restored the pubkey + UI but not the in-memory signer after a
* page reload (the private key is only passphrase-encrypted — see TODO #36),
* prompt once for the passphrase and re-unlock the signer app-wide via
* `unlockStoredSessionActor`. One unlock re-enables every signing/encryption
* flow (hashtags, subscriptions, blocks, DMs, reactions, storage) until the
* next reload — instead of each guard dead-ending with a blanket
* "connect a signer that supports encryption" error (TODO #51/#54/#56/#57).
*
* @param {object} [opts]
* @param {string} [opts.pubkey] - Account to unlock (defaults to the active one).
* @param {"encrypt"|"sign"} [opts.need] - Capability required. "encrypt" needs
* nip44/nip04; "sign" needs signEvent only.
* @param {string} [opts.promptMessage] - Override the passphrase-prompt copy.
* @returns {Promise<{ok: boolean, reason?: string, unlocked?: boolean, error?: Error}>}
*/
async ensureEncryptionCapableSigner({
pubkey,
need = "encrypt",
promptMessage,
} = {}) {
const normalized =
this.normalizeHexPubkey(pubkey) ||
this.normalizeHexPubkey(this.pubkey) ||
"";
let meta = null;
try {
meta =
normalized &&
typeof nostrClient?.getStoredSessionActorMetadata === "function"
? nostrClient.getStoredSessionActorMetadata(normalized)
: null;
} catch (error) {
devLogger.warn(
"[Application] Failed to read stored session metadata while ensuring signer:",
error,
);
}
// Try a cached "keep unlocked" key first so we never prompt after a reload
// when the user opted to stay unlocked (TODO #51).
if (
normalized &&
typeof nostrClient?.restoreUnlockedSigner === "function" &&
!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
) {
try {
const restored = await nostrClient.restoreUnlockedSigner(normalized);
if (
restored?.restored &&
isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)
) {
return { ok: true, restored: true };
}
} catch (error) {
devLogger.warn("[Application] Cached unlock restore failed:", error);
}
}
const decision = decideSignerEnsure({
capabilities: resolveSignerCapabilities(getActiveSigner()),
need,
normalizedPubkey: normalized,
storedKeyMeta: meta,
normalizePubkey: (value) => this.normalizeHexPubkey(value) || "",
});
if (decision.action === "ok") {
return { ok: true };
}
if (decision.action === "fail") {
return { ok: false, reason: decision.reason };
}
const message =
typeof promptMessage === "string" && promptMessage.trim()
? promptMessage.trim()
: "Re-enter your PIN / passphrase to unlock your saved key for this action.";
const promptResult = await showPasswordPrompt(message, {
title: "Unlock your key",
confirmLabel: "Unlock",
collectRemember: true,
});
if (promptResult == null) {
return { ok: false, reason: "cancelled" };
}
const passphrase =
typeof promptResult === "string" ? promptResult : promptResult.passphrase;
const persistUnlock =
typeof promptResult === "object" ? Boolean(promptResult.remember) : false;
if (passphrase == null || passphrase === "") {
return { ok: false, reason: "cancelled" };
}
try {
await nostrClient.unlockStoredSessionActor(passphrase, {
pubkey: normalized,
persistUnlock,
});
} catch (error) {
if (error?.code === "decrypt-failed") {
this.showError?.(
"Incorrect PIN / passphrase. Try that action again to re-enter it.",
);
return { ok: false, reason: "bad-passphrase", error };
}
devLogger.warn(
"[Application] Failed to unlock stored nsec session for a signing action:",
error,
);
return { ok: false, reason: "unlock-failed", error };
}
// The signer is now registered app-wide; confirm the capability actually
// came back before telling the caller to proceed.
if (!isSignerCapable(resolveSignerCapabilities(getActiveSigner()), need)) {
return { ok: false, reason: "still-incapable" };
}
return { ok: true, unlocked: true };
}
// True when the active (or given) account currently has a cached "keep
// unlocked" key — i.e. there is something to lock (TODO #51).
isSessionKeptUnlocked(pubkey) {
const normalized =
this.normalizeHexPubkey(pubkey) ||
this.normalizeHexPubkey(this.pubkey) ||
"";
if (!normalized || typeof nostrClient?.hasCachedUnlockedKey !== "function") {
return false;
}
try {
return nostrClient.hasCachedUnlockedKey(normalized);
} catch (error) {
return false;
}
}
// "Lock now": re-lock this device immediately AND forget the cached key so a
// future reload re-prompts. Counterpart to the opt-in "keep unlocked" (TODO
// #51). Locks everything the signer unlocks — the in-memory signer, storage,
// and the decrypted NWC cache — so the account is fully re-locked in one action.
lockKeptUnlockedSession(pubkey) {
const normalized =
this.normalizeHexPubkey(pubkey) ||
this.normalizeHexPubkey(this.pubkey) ||
"";
if (!normalized) {
return { ok: false, reason: "no-pubkey" };
}
// 1. Forget the cached key (both tiers) → future reloads re-prompt for the PIN.
try {
if (typeof nostrClient.forgetUnlockedSigner === "function") {
nostrClient.forgetUnlockedSigner(normalized);
}
} catch (error) {
devLogger.warn("[Application] Failed to forget cached unlock on lock:", error);
}
// 2. Remove the in-memory nsec signer → the CURRENT session re-locks now.
try {
logoutSigner(normalized);
} catch (error) {
devLogger.warn("[Application] Failed to drop the active signer on lock:", error);
}
// 3. Lock storage (drops the in-memory master key).
try {
if (typeof storageService.lock === "function") {
storageService.lock(normalized);
}
} catch (error) {
devLogger.warn("[Application] Failed to lock storage on lock:", error);
}
// 4. Drop the decrypted NWC cache (re-hydrates from the signer next time).
try {
if (typeof this.nwcSettingsService?.clearCache === "function") {
this.nwcSettingsService.clearCache();
}
} catch (error) {
devLogger.warn("[Application] Failed to drop the NWC cache on lock:", error);
}
this.showSuccess?.(
"Locked on this device. You'll re-enter your PIN next time it's needed.",
);
return { ok: true };
}
resetPermissionPromptState() {
this.permissionPromptShownForSession = false;
this.permissionPromptVisible = false;
@@ -2905,24 +3124,17 @@ class Application {
signedEvent,
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "dm relay hints update",
message: "No relays accepted the DM relay list.",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[Application] Relay ${url} rejected DM relay list: ${reason}`,
relayError || reason,
);
},
);
}
throw publishError;
// An all-timeout (unconfirmed) publish of the replaceable DM relay list is a
// soft success — sent + almost always persisted; don't error/revert. Explicit
// rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "dm relay hints update",
message: "No relays accepted the DM relay list.",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
"[Application] DM relay list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
if (publishSummary.failed.length) {
@@ -3197,8 +3409,9 @@ class Application {
}
let relaySummary = null;
let publishResult = null;
try {
const publishResult = await this.nostrService.publishVideoNote(
publishResult = await this.nostrService.publishVideoNote(
publishPayload,
this.pubkey,
);
@@ -3219,10 +3432,31 @@ class Application {
this.uploadModal.close();
}
// Optimistically insert the just-published note into the shared cache so it
// appears in the feed instantly, before the relays echo it back. Obeys the
// same dedupe/active-key rules as live ingestion, so a later relay copy
// won't duplicate it (see #20/#21).
try {
if (publishResult?.legacy) {
this.nostrService.ingestLocalVideoEvent(publishResult.legacy);
}
} catch (error) {
devLogger.warn(
"[Application] Optimistic feed insert after publishing failed:",
error,
);
}
let loadVideosError = null;
try {
await this.loadVideos();
// Refresh whichever feed is active (For You / Explore / Recent) plus the
// subscription and channel grids — not just the main recent feed — and
// force a render so the newly injected video is shown.
await this.refreshAllVideoGrids({
reason: "video-published",
forceMainReload: true,
});
} catch (error) {
loadVideosError = error;
devLogger.error(
@@ -3314,6 +3548,17 @@ class Application {
return this._auth.handleProfileSwitchRequest(...args);
}
// Coordinator methods are bound to `this` (the Application) by bindCoordinator,
// so a sibling call like `this.prepareStoredNsecSwitch(...)` inside
// handleProfileSwitchRequest resolves against the Application — it needs this
// delegator to exist (like handleProfileSwitchRequest above). Without it,
// switching to a stored-nsec account threw "this.prepareStoredNsecSwitch is not
// a function".
async prepareStoredNsecSwitch(...args) {
this._initCoordinators();
return this._auth.prepareStoredNsecSwitch(...args);
}
async waitForIdentityRefresh(...args) {
this._initCoordinators();
return this._auth.waitForIdentityRefresh(...args);
@@ -3389,6 +3634,29 @@ class Application {
return this._ui.updateActiveProfileUI(...args);
}
// The active user's most-used hashtags (frequency-ranked from their own past
// uploads) for the upload modal's one-tap suggestion chips (TODO #45).
getUserHashtagSuggestions({ limit = 12 } = {}) {
const pubkey = this.normalizeHexPubkey(this.pubkey);
if (
!pubkey ||
typeof this.nostrService?.getActiveVideosByAuthors !== "function"
) {
return [];
}
let videos = [];
try {
videos = this.nostrService.getActiveVideosByAuthors([pubkey]) || [];
} catch (error) {
devLogger.warn(
"[Application] Failed to read user videos for hashtag suggestions:",
error,
);
return [];
}
return rankHashtagsByFrequency(videos, { limit });
}
async hydrateNwcSettingsForPubkey(pubkey) {
return this.nwcSettingsService.hydrateNwcSettingsForPubkey(pubkey);
}
+137 -1
View File
@@ -13,6 +13,8 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js";
import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js";
import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js";
import { showPasswordPrompt } from "../ui/promptDialog.js";
import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
/**
* Kick a background watch-history refresh after login, once the feed-driving
@@ -648,6 +650,26 @@ export function createAuthSessionCoordinator(deps) {
authLoadingState: this.authLoadingState,
});
// Now that the NEW identity's lists (blocks/subscriptions/hashtags) have
// settled, backfill the profile modal if it is open — so switching
// accounts with the modal visible refreshes the pane the user is viewing
// with the freshly-loaded data instead of leaving the previous account's
// list on screen until a manual refresh. No-op when the modal is closed.
if (
this.profileController &&
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
"function"
) {
try {
this.profileController.refreshOpenPanesForActiveIdentity();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh profile panes after lists settled:",
error,
);
}
}
// Warm the watch history in the background now that the feed-driving
// lists (blocks/subscriptions/hashtags) have finished decrypting. This
// runs AFTER the required lists — never concurrently — so it does not
@@ -951,6 +973,21 @@ export function createAuthSessionCoordinator(deps) {
clearDecryptionSchemeCache();
clearWatchHistoryConversationKeyCache();
clearWatchHistoryDecryptedChunkCache();
// Forget any cached "keep unlocked" key for the account being logged out, so
// logout actually re-locks it (TODO #51).
try {
const loggedOutPubkey = detail?.pubkey || this.pubkey;
if (
loggedOutPubkey &&
typeof nostrClient.forgetUnlockedSigner === "function"
) {
nostrClient.forgetUnlockedSigner(loggedOutPubkey);
}
} catch (error) {
devLogger.warn("Failed to forget cached unlock during logout:", error);
}
this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" });
try {
@@ -1372,12 +1409,78 @@ export function createAuthSessionCoordinator(deps) {
}
},
// For an nsec target, ensure its stored key can be unlocked and collect the
// passphrase. Returns { status: "ok", passphrase } | { status: "cancelled" } |
// { status: "error", reason }.
async prepareStoredNsecSwitch(targetPubkey) {
const meta =
typeof nostrClient?.getStoredSessionActorMetadata === "function"
? nostrClient.getStoredSessionActorMetadata(targetPubkey)
: null;
const decision = evaluateStoredNsecSwitch(meta, targetPubkey);
if (decision.action === "error") {
this.showError?.(
decision.reason === "pubkey-mismatch"
? "A different account's key is saved on this device. Log in with this account's nsec to switch to it."
: "This account's key isn't saved on this device. Log in with its nsec to switch to it.",
);
return { status: "error", reason: decision.reason };
}
const passphrase = await showPasswordPrompt(
"Enter your PIN / passphrase to unlock this account's saved key.",
{ title: "Switch account", confirmLabel: "Switch" },
);
if (passphrase == null || passphrase === "") {
return { status: "cancelled", reason: "cancelled" };
}
return { status: "ok", passphrase };
},
async handleProfileSwitchRequest({ pubkey, providerId } = {}) {
if (!pubkey) {
throw new Error("Missing pubkey for profile switch request.");
}
const result = await this.authService.switchProfile(pubkey, { providerId });
// Different signing methods need help to activate on switch:
// - nsec: its key is passphrase-encrypted → prompt for the PIN + unlock it.
// - NIP-46: reuse the stored remote-signer session (don't restart a handshake).
// - NIP-07: the extension connects directly, no prep needed.
const normalizedProviderId = String(providerId || "").trim().toLowerCase();
const isNsecTarget = normalizedProviderId === "nsec";
const isNip46Target = normalizedProviderId === "nip46";
let switchOptions = { providerId };
if (isNsecTarget) {
const prep = await this.prepareStoredNsecSwitch(pubkey);
if (prep.status !== "ok") {
return { switched: false, reason: prep.reason || prep.status };
}
switchOptions = {
providerId,
unlockStored: true,
passphrase: prep.passphrase,
};
} else if (isNip46Target) {
switchOptions = { providerId, reuseStored: true };
}
let result;
try {
result = await this.authService.switchProfile(pubkey, switchOptions);
} catch (error) {
if (isNsecTarget && error?.code === "decrypt-failed") {
this.showError?.(
"Incorrect PIN / passphrase. Try switching to this account again.",
);
return { switched: false, reason: "bad-passphrase" };
}
if (isNip46Target) {
this.showError?.(
"Couldn't reconnect to this account's remote signer. Reconnect it (Login → remote signer) to switch to it.",
);
return { switched: false, reason: "nip46-reconnect-failed" };
}
throw error;
}
if (result?.switched) {
const detail = result.detail || null;
@@ -1584,6 +1687,18 @@ export function createAuthSessionCoordinator(deps) {
}
}
// Removing a saved profile must also forget its cached "keep unlocked" key.
try {
if (typeof nostrClient.forgetUnlockedSigner === "function") {
nostrClient.forgetUnlockedSigner(normalizedTarget);
}
} catch (error) {
devLogger.warn(
"[Application] Failed to forget cached unlock for removed profile:",
error,
);
}
this.renderSavedProfiles();
return { loggedOut: true, removed: true };
@@ -1707,6 +1822,27 @@ export function createAuthSessionCoordinator(deps) {
return context;
}
// Block lists are NIP-04 encrypted. A reloaded nsec session can lose its
// in-memory key; re-unlock it (one passphrase prompt) rather than failing
// with a blanket signer error (TODO #57). Only short-circuit on the
// user-driven outcomes — anything else falls through so the mutation's own
// signer error (e.g. an unresponsive NIP-07 extension) still surfaces.
if (typeof this.ensureEncryptionCapableSigner === "function") {
const ensured = await this.ensureEncryptionCapableSigner({
pubkey: actorHex,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and update your mute/block list.",
});
if (
ensured &&
(ensured.reason === "cancelled" || ensured.reason === "bad-passphrase")
) {
context.reason = ensured.reason;
return context;
}
}
try {
await userBlocks.ensureLoaded(actorHex);
const isBlocked = userBlocks.isBlocked(targetHex);
+24
View File
@@ -0,0 +1,24 @@
// Decide whether switching to a saved nsec account can proceed via the stored key.
// Pure so the branching is unit-testable; the coordinator handles the prompt + I/O.
//
// { action: "prompt" } → stored nsec key matches; ask for the PIN
// { action: "error", reason: "no-stored-key" } → no persisted nsec key on this device
// { action: "error", reason: "pubkey-mismatch" } → the saved key is a different account
function normalizeHex(value) {
return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value.trim())
? value.trim().toLowerCase()
: "";
}
export function evaluateStoredNsecSwitch(meta, targetPubkey) {
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return { action: "error", reason: "no-stored-key" };
}
const stored = normalizeHex(meta.pubkey);
const target = normalizeHex(targetPubkey);
if (stored && target && stored !== target) {
return { action: "error", reason: "pubkey-mismatch" };
}
return { action: "prompt" };
}
+135 -6
View File
@@ -76,6 +76,36 @@ import { RELAY_BACKGROUND_CONCURRENCY } from "./nostr/relayConstants.js";
const getApp = () => getApplication();
// A single hung relay (one that never sends EOSE) used to stall the whole
// channel-grid fetch because `pool.list` has no built-in timeout. Bound each
// relay's list() so a slow relay resolves to "no events" instead of holding up
// the merged render — the other relays + the shared cache still fill the grid.
export const CHANNEL_RELAY_LIST_TIMEOUT_MS = 8000;
// Pure, testable core: race a list() call (via a factory) against a timeout.
// Resolves to the list's array on success, or [] on timeout OR rejection — a
// failing/hung relay contributes nothing rather than stalling or throwing (and
// a late rejection after the timeout wins can't surface as unhandled).
export function raceListWithTimeout(listFn, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
let timer = null;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve([]), timeoutMs);
});
const listPromise = Promise.resolve()
.then(() => listFn())
.then((result) => (Array.isArray(result) ? result : []))
.catch(() => []);
return Promise.race([listPromise, timeout]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
}
function listRelayWithTimeout(relays, filters, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) {
return raceListWithTimeout(() => nostrClient.pool.list(relays, filters), timeoutMs);
}
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge";
@@ -1310,6 +1340,17 @@ let zapPopoverOpenPromise = null;
let channelMenuPopover = null;
let channelMenuOpen = false;
let currentVideoLoadToken = 0;
// Coalescing state for channel-grid loads. loadUserVideos() bumps
// currentVideoLoadToken and only renders if its token is still current, so a
// burst of refresh triggers (e.g. the NIP-71 ingest flushing repeatedly right
// after open) can supersede every in-flight load before it paints — the grid
// gets stuck on "Loading videos…". requestChannelVideoLoad() collapses those:
// while a load for the same channel is in flight, a refresh flags a single
// trailing rerun instead of starting a competing load. Navigation to a channel
// (isRefresh:false) still starts immediately (and supersedes a stale channel).
let channelVideoLoadInFlight = null;
let channelVideoLoadPubkey = null;
let channelVideoRerunPending = false;
let currentProfileLoadToken = 0;
const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg";
@@ -3821,7 +3862,7 @@ export async function initChannelProfileView() {
syncChannelShareButtonState();
});
const videosPromise = loadUserVideos(hexPub).catch((error) => {
const videosPromise = requestChannelVideoLoad(hexPub).catch((error) => {
userLogger.error("Failed to load channel videos:", error);
});
@@ -4205,6 +4246,32 @@ function renderSubscribeButton(channelHex) {
return;
}
// Subscriptions (kind 30000) are NIP-04 encrypted. A reloaded nsec session
// can lose its in-memory key; re-unlock it with one passphrase prompt rather
// than failing with a blanket signer error (TODO #57). Gate BEFORE the
// optimistic update so the button doesn't flash on cancel. Only the
// user-driven outcomes short-circuit; anything else falls through to
// toggleChannel's own error handling below.
if (typeof currentApp.ensureEncryptionCapableSigner === "function") {
let ensured = { ok: true };
try {
ensured = await currentApp.ensureEncryptionCapableSigner({
pubkey: currentApp.pubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and update your subscriptions.",
});
} catch (error) {
ensured = { ok: false, reason: "ensure-failed" };
}
if (
ensured &&
(ensured.reason === "cancelled" || ensured.reason === "bad-passphrase")
) {
return;
}
}
// Optimistic UI update
const wasSubscribed = toggleBtn.dataset.state === "subscribed";
const nextState = wasSubscribed ? "unsubscribed" : "subscribed";
@@ -4448,7 +4515,26 @@ function getCachedChannelVideoEvents(pubkey) {
}
);
return [...rawMatches, ...processedMatches];
// Parity with the other grids: also pull the author's videos from the shared
// feed cache (nostrService videosMap → author index) — the SAME source the
// main/subscriptions grids render from. The channel grid otherwise sources
// only nostrClient.allEvents + a scoped relay fetch, which can DIVERGE from
// that cache, so a video shown on another grid could be missing here. Appended
// last so it only fills gaps (richer raw/processed copies win the id-dedupe).
let parityMatches = [];
try {
const service = getApp()?.nostrService;
if (service && typeof service.getActiveVideosByAuthors === "function") {
const parity = service.getActiveVideosByAuthors([normalized]);
if (Array.isArray(parity)) {
parityMatches = parity;
}
}
} catch (error) {
// Best-effort; the allEvents scan + scoped relay fetch still populate the grid.
}
return [...rawMatches, ...processedMatches, ...parityMatches];
}
function normalizeRenderableChannelVideo(entry) {
@@ -5315,7 +5401,7 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
try {
const result = loadUserVideos(currentChannelHex);
const result = requestChannelVideoLoad(currentChannelHex, { isRefresh: true });
return result instanceof Promise ? result : Promise.resolve(result);
} catch (error) {
userLogger.warn(
@@ -5328,6 +5414,49 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) {
}
}
// Entry point for channel-grid loads. Coalesces refresh-driven loads so a
// storm of refreshes can't perpetually cancel each other before rendering.
// isRefresh:true → if a load for the same channel is in flight, flag one
// trailing rerun and reuse the in-flight promise.
// isRefresh:false → navigation; always start (supersedes a stale channel).
function requestChannelVideoLoad(pubkey, { isRefresh = false } = {}) {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (
isRefresh &&
channelVideoLoadInFlight &&
channelVideoLoadPubkey === normalized
) {
channelVideoRerunPending = true;
return channelVideoLoadInFlight;
}
return startChannelVideoLoad(pubkey);
}
function startChannelVideoLoad(pubkey) {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
channelVideoLoadPubkey = normalized;
const promise = Promise.resolve(loadUserVideos(pubkey)).finally(() => {
if (channelVideoLoadInFlight === promise) {
channelVideoLoadInFlight = null;
}
// Fire at most one trailing reload, and only if we're still on this
// channel (navigation elsewhere will have started its own load).
if (channelVideoRerunPending) {
channelVideoRerunPending = false;
const stillHere =
typeof currentChannelHex === "string" &&
currentChannelHex.trim().toLowerCase() === normalized;
if (stillHere) {
startChannelVideoLoad(currentChannelHex);
}
}
});
channelVideoLoadInFlight = promise;
return promise;
}
async function loadUserVideos(pubkey) {
const app = getApp();
const container = document.getElementById("channelVideoList");
@@ -5398,7 +5527,7 @@ async function loadUserVideos(pubkey) {
if (relayList.length === 0) {
try {
const fallbackEvents = await nostrClient.pool.list(
const fallbackEvents = await listRelayWithTimeout(
Array.from(DEFAULT_RELAY_URLS),
filters
);
@@ -5411,7 +5540,7 @@ async function loadUserVideos(pubkey) {
} else {
const settled = await pMapSettled(
relayList,
(url) => nostrClient.pool.list([url], filters),
(url) => listRelayWithTimeout([url], filters),
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
);
settled.forEach((result, index) => {
@@ -5493,7 +5622,7 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio
return;
}
loadUserVideos(currentChannelHex).catch((error) => {
requestChannelVideoLoad(currentChannelHex, { isRefresh: true }).catch((error) => {
userLogger.error(
"Failed to refresh channel videos after admin update:",
error
+47 -1
View File
@@ -55,7 +55,9 @@ export function createTrendingSorter() {
const live = items.filter((entry) => !isMuted(entry));
const muted = items.filter((entry) => isMuted(entry));
return [...live.sort(compare), ...muted.sort(compare)];
// Rank by views, then spread authors so the tab isn't walls of one creator.
// The most-viewed item is still chosen first, so it stays topmost.
return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)];
};
}
@@ -98,6 +100,47 @@ export function interleaveByAuthor(items, { authorOf } = {}) {
return result;
}
// Gentle author-diversity re-rank. Unlike interleaveByAuthor (full round-robin,
// which flattens the ranking), this keeps the caller's ranked order as the
// priority and only breaks up runs of the same author: at each step it takes the
// highest-ranked remaining item whose author wasn't among the last `window`
// picks, falling back to the plain top item when every remaining candidate is a
// recent author. The #1 ranked item is always chosen first, so "best/most-X on
// top" is preserved while consecutive cards stop clustering by creator.
export function spreadAuthors(items, { authorOf, window: windowSize = 1 } = {}) {
if (!Array.isArray(items) || items.length < 3) {
return Array.isArray(items) ? [...items] : [];
}
const size = Number.isFinite(windowSize) && windowSize > 0 ? Math.floor(windowSize) : 1;
const keyOf =
typeof authorOf === "function"
? authorOf
: (entry) =>
typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : "";
const remaining = items.map((item, index) => ({ item, key: keyOf(item), index }));
const result = [];
const recent = []; // authors of the last `size` picks
while (remaining.length) {
let pickIdx = 0; // default: the highest-ranked remaining item
for (let i = 0; i < remaining.length; i += 1) {
const { key } = remaining[i];
// Anonymous/unknown authors ("") never count as a repeat.
if (!key || !recent.includes(key)) {
pickIdx = i;
break;
}
}
const [picked] = remaining.splice(pickIdx, 1);
result.push(picked.item);
if (picked.key) {
recent.push(picked.key);
if (recent.length > size) recent.shift();
}
}
return result;
}
// Ranks the For You feed "your people first": followed authors and interest/
// watch-topic matches (forYouTier, set by the scorer) lead regardless of
// recency, then everyone else by score. When NOTHING is tiered (logged-out / no
@@ -154,6 +197,9 @@ export function createForYouScoreSorter() {
if (tierDiff !== 0) return tierDiff;
return byScore(a, b);
});
// Break up per-creator clusters (a followed author's whole back-catalogue
// would otherwise sit in one block) while keeping the best-fit item on top.
ordered = spreadAuthors(ordered);
} else {
// No signals → discovery fallback: score order, then interleave authors so
// it doesn't read as a chronological clone of Recently-added.
+190 -11
View File
@@ -18,6 +18,7 @@
*/
import { isDevMode } from "../config.js";
import { showConfirm } from "../ui/confirmDialog.js";
import { infoHashFromMagnet } from "../magnets.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import {
@@ -172,7 +173,12 @@ import {
readStoredSessionActorEntry,
isSessionActor,
} from "./sessionActor.js";
import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js";
import { HEX64_REGEX, normalizeHexHash, bytesToHex } from "../utils/hex.js";
import {
rememberUnlockedKey,
readUnlockedKey,
forgetUnlockedKey,
} from "./unlockedKeyCache.js";
import {
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
@@ -2596,10 +2602,10 @@ export class NostrClient {
// Metadata about a stored, passphrase-encrypted nsec session (no secrets).
// The login modal reads `hasEncryptedKey` to offer the "unlock saved key"
// flow; without this the unlock UI never appeared.
getStoredSessionActorMetadata() {
getStoredSessionActorMetadata(pubkey) {
let entry = null;
try {
entry = readStoredSessionActorEntry();
entry = readStoredSessionActorEntry(pubkey);
} catch (error) {
devLogger.warn(
"[nostr] Failed to read stored session actor metadata:",
@@ -2622,14 +2628,19 @@ export class NostrClient {
// passphrase and re-activating the signer via the same path as fresh login.
// The access-control validator is enforced before activation, and a
// now-blocked key is forgotten.
async unlockStoredSessionActor(passphrase, { validator } = {}) {
async unlockStoredSessionActor(
passphrase,
{ validator, pubkey: targetPubkey, cacheUnlock = true, persistUnlock = false } = {},
) {
if (typeof passphrase !== "string" || !passphrase.trim()) {
const error = new Error("A passphrase is required to unlock the saved key.");
error.code = "passphrase-required";
throw error;
}
const entry = readStoredSessionActorEntry();
// When switching accounts, `targetPubkey` selects that account's stored key
// so we unlock the one the user asked for — not just the last-saved default.
const entry = readStoredSessionActorEntry(targetPubkey);
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
const error = new Error("There is no saved key to unlock.");
error.code = "no-stored-session";
@@ -2645,7 +2656,8 @@ export class NostrClient {
validator(storedPubkey);
} catch (error) {
try {
clearStoredSessionActorEntry();
// Forget only the now-blocked account, not every saved key.
clearStoredSessionActorEntry(storedPubkey);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear stored session after access denial:",
@@ -2706,9 +2718,98 @@ export class NostrClient {
(adapter && typeof adapter.pubkey === "string" && adapter.pubkey) ||
pubkey ||
"";
// Cache the decrypted key so a reload doesn't require the passphrase again
// (TODO #51). Session tier is always written (survives refresh, cleared on tab
// close); the persistent tier (on disk) only when the user opted in.
if (cacheUnlock && resolvedPubkey) {
try {
rememberUnlockedKey(resolvedPubkey, normalizedPrivateKey, {
persist: Boolean(persistUnlock),
});
} catch (error) {
devLogger.warn("[nostr] Failed to cache unlocked key:", error);
}
}
return { pubkey: resolvedPubkey };
}
// Restore an nsec signer from the "keep unlocked" cache without a passphrase
// (TODO #51). Reads the cached decrypted key for `targetPubkey`, verifies it
// derives to that pubkey (defense-in-depth; a mismatch forgets the cache), and
// registers it app-wide. Returns { restored: boolean }.
async restoreUnlockedSigner(targetPubkey) {
const pubkey =
typeof targetPubkey === "string" && HEX64_REGEX.test(targetPubkey.trim().toLowerCase())
? targetPubkey.trim().toLowerCase()
: "";
if (!pubkey) {
return { restored: false, reason: "no-pubkey" };
}
const privateKeyHex = readUnlockedKey(pubkey);
if (!HEX64_REGEX.test(privateKeyHex || "")) {
return { restored: false, reason: "no-cache" };
}
let derivedPubkey = "";
try {
const tools = (await ensureNostrTools()) || getCachedNostrTools();
if (tools && typeof tools.getPublicKey === "function") {
derivedPubkey = String(tools.getPublicKey(privateKeyHex) || "").toLowerCase();
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to derive pubkey while restoring unlocked signer:",
error,
);
}
if (derivedPubkey && derivedPubkey !== pubkey) {
// Corrupt / mismatched cache — never adopt a different account's key.
forgetUnlockedKey(pubkey);
return { restored: false, reason: "pubkey-mismatch" };
}
try {
const adapter = await this.registerPrivateKeySigner({
privateKey: privateKeyHex,
pubkey,
persist: false,
});
if (this.sessionActor && typeof this.sessionActor === "object") {
this.sessionActor.persisted = true;
}
return {
restored: true,
pubkey:
(adapter && typeof adapter.pubkey === "string" && adapter.pubkey) ||
pubkey,
};
} catch (error) {
devLogger.warn("[nostr] Failed to restore unlocked signer:", error);
return { restored: false, reason: "register-failed", error };
}
}
// True if a "keep unlocked" key is cached for this account (session or disk).
hasCachedUnlockedKey(targetPubkey) {
try {
return Boolean(readUnlockedKey(targetPubkey));
} catch (error) {
return false;
}
}
// Forget any cached "keep unlocked" key for an account (both tiers).
forgetUnlockedSigner(targetPubkey) {
try {
forgetUnlockedKey(targetPubkey);
} catch (error) {
devLogger.warn("[nostr] Failed to forget unlocked key:", error);
}
}
/**
* Publishes a fresh Video Series (Kind 30078) to the network.
* This is the entry point for creating new content.
@@ -2862,6 +2963,27 @@ export class NostrClient {
}
async ensureActiveSignerForPubkey(pubkey) {
// If the registry has no sign-capable signer (e.g. a reload restored the
// pubkey/UI but not the in-memory signer) and a "keep unlocked" nsec key is
// cached, restore it before falling back to the extension path — so a reload
// doesn't require the passphrase (TODO #51). Never adopts a different account
// (restoreUnlockedSigner verifies the derived pubkey).
const existing = getActiveSigner();
if (!existing || typeof existing.signEvent !== "function") {
const normalized =
typeof pubkey === "string" && HEX64_REGEX.test(pubkey.trim().toLowerCase())
? pubkey.trim().toLowerCase()
: "";
if (normalized && readUnlockedKey(normalized)) {
const restored = await this.restoreUnlockedSigner(normalized);
if (restored?.restored) {
const signer = getActiveSigner();
if (signer && typeof signer.signEvent === "function") {
return signer;
}
}
}
}
return this.signerManager.ensureActiveSignerForPubkey(pubkey);
}
@@ -2915,8 +3037,8 @@ export class NostrClient {
return this.signerManager.onRemoteSignerChange(listener);
}
getStoredNip46Metadata() {
return this.signerManager.getStoredNip46Metadata();
getStoredNip46Metadata(pubkey) {
return this.signerManager.getStoredNip46Metadata(pubkey);
}
async ensureExtensionPermissions(
@@ -3465,9 +3587,10 @@ export class NostrClient {
options && typeof options.video === "object" ? options.video : null;
let confirmed = true;
if (shouldConfirm && typeof window?.confirm === "function") {
confirmed = window.confirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone."
if (shouldConfirm) {
confirmed = await showConfirm(
"Are you sure you want to delete all versions of this video? This action cannot be undone.",
{ confirmLabel: "Delete all", danger: true },
);
}
@@ -4301,6 +4424,62 @@ export class NostrClient {
);
}
// Optimistically add a just-published video event to the local caches so it
// shows in the feed immediately, without waiting for it to propagate back
// from the relays. Mirrors the per-event ingestion the live subscription and
// fetchVideosByAuthors use (convert → root created_at → tombstone guard →
// allEvents/activeMap), so it obeys the same dedupe/active-key rules — a later
// relay copy of the same event (same created_at) won't displace it, and an
// older revision can't overwrite a newer active entry. Returns the ingested
// video, or null if the event isn't a usable video note.
ingestLocalVideoEvent(rawEvent) {
if (!rawEvent || typeof rawEvent !== "object" || !rawEvent.id) {
return null;
}
let video;
try {
video = convertEventToVideo(rawEvent);
} catch (error) {
devLogger.warn(
"[nostr] Failed to convert published event for optimistic feed insert:",
error,
);
return null;
}
if (!video || video.invalid) {
return null;
}
this.applyRootCreatedAt(video);
if (this.rawEvents && typeof this.rawEvents.set === "function") {
this.rawEvents.set(rawEvent.id, rawEvent);
}
const activeKey = getActiveKey(video);
if (video.deleted) {
this.recordTombstone(activeKey, video.created_at);
return video;
}
this.applyTombstoneGuard(video);
if (video.deleted) {
// A tombstone guard flipped it to deleted — don't surface it.
return video;
}
this.allEvents.set(rawEvent.id, video);
this.dirtyEventIds.add(rawEvent.id);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
this.applyRootCreatedAt(video);
}
return video;
}
handleEvent(event) {
if (!event || typeof event !== "object") return;
+56
View File
@@ -0,0 +1,56 @@
// Pure decision for `app.ensureEncryptionCapableSigner`. Given the active signer's
// current capabilities and whether a locked (passphrase-encrypted) nsec key exists
// for the active account, decide whether to proceed, fail, or prompt the user to
// re-unlock their key. Kept side-effect-free so the branching is unit-testable
// without the browser app shell (TODO #51/#54/#56/#57).
//
// Returns one of:
// { action: "ok" } — signer already capable
// { action: "prompt" } — recoverable: prompt + unlock nsec
// { action: "fail", reason: <string> } — not recoverable here
const defaultNormalize = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
export function isSignerCapable(capabilities, need = "encrypt") {
const caps =
capabilities && typeof capabilities === "object" ? capabilities : {};
if (!caps.sign) {
return false;
}
return need === "sign" ? true : Boolean(caps.nip44) || Boolean(caps.nip04);
}
export function decideSignerEnsure({
capabilities,
need = "encrypt",
normalizedPubkey,
storedKeyMeta,
normalizePubkey = defaultNormalize,
} = {}) {
if (isSignerCapable(capabilities, need)) {
return { action: "ok" };
}
if (!normalizedPubkey) {
return { action: "fail", reason: "no-signer" };
}
// Only an nsec-after-reload situation is recoverable here: a saved, locked key
// for this exact account we can decrypt with the passphrase. Anything else
// (e.g. an unresponsive NIP-07 extension) is the caller's own error to surface.
if (!storedKeyMeta || storedKeyMeta.hasEncryptedKey !== true) {
return { action: "fail", reason: "no-stored-key" };
}
if (
storedKeyMeta.pubkey &&
normalizePubkey(storedKeyMeta.pubkey) !== normalizedPubkey
) {
return { action: "fail", reason: "pubkey-mismatch" };
}
return { action: "prompt" };
}
export default decideSignerEnsure;
+12 -5
View File
@@ -778,8 +778,14 @@ export class SignerManager {
typeof normalizedOptions.validator === "function"
? normalizedOptions.validator
: null;
// When switching accounts, select that account's stored session so we
// reconnect the one the user asked for — not just the last-connected default.
const targetPubkey =
typeof normalizedOptions.pubkey === "string"
? normalizedOptions.pubkey.trim().toLowerCase()
: "";
let stored = readStoredNip46Session();
let stored = readStoredNip46Session(targetPubkey || undefined);
if (!stored) {
const error = new Error(
"No remote signer session is stored on this device.",
@@ -850,9 +856,10 @@ export class SignerManager {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
// Always forget a stored session that was rejected by access control —
// no point keeping a blocked pubkey's session to retry.
// no point keeping a blocked pubkey's session to retry. Forget only the
// account we tried, leaving other saved sessions switchable.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
clearStoredNip46Session();
clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined);
}
throw err;
}
@@ -947,8 +954,8 @@ export class SignerManager {
this.emitRemoteSignerChange();
}
getStoredNip46Metadata() {
const stored = readStoredNip46Session() || {};
getStoredNip46Metadata(pubkey) {
const stored = readStoredNip46Session(pubkey) || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
+171 -2
View File
@@ -46,6 +46,13 @@ import { SHORT_TIMEOUT_MS } from "../constants.js";
export const NIP46_RPC_KIND = 24_133;
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
// Per-user-pubkey remote-signer session store, mirroring the per-account nsec
// key store (bitvid:sessionActors:v2). Lets several saved NIP-46 accounts each
// keep their own reconnectable session so the profile switcher can move between
// them. The v1 single slot above is kept in sync as the last-connected default
// (boot restore) and migrated into this map on first read.
const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
const NIP46_SESSIONS_MAP_VERSION = 2;
const NIP46_PUBLISH_TIMEOUT_MS = 8_000;
const NIP46_PING_TIMEOUT_MS = SHORT_TIMEOUT_MS;
const NIP46_RESPONSE_TIMEOUT_MS = 15_000;
@@ -248,7 +255,88 @@ export function sanitizeStoredNip46Session(candidate) {
};
}
export function readStoredNip46Session() {
function normalizeNip46PubkeyKey(pubkey) {
return typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
}
// Read the per-user-pubkey session map { [userPubkeyLower]: session }.
function readNip46SessionsMap() {
const storage = getNip46Storage();
if (!storage) {
return {};
}
let raw = null;
try {
raw = storage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (error) {
return {};
}
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
const entries =
parsed && typeof parsed === "object" ? parsed.entries : null;
if (!entries || typeof entries !== "object") {
return {};
}
const map = {};
for (const key of Object.keys(entries)) {
const session = sanitizeStoredNip46Session(entries[key]);
if (!session) {
continue;
}
const normalizedKey = normalizeNip46PubkeyKey(session.userPubkey || key);
if (normalizedKey) {
map[normalizedKey] = session;
}
}
return map;
} catch (error) {
try {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt NIP-46 session map:",
cleanupError,
);
}
return {};
}
}
function writeNip46SessionsMap(map) {
const storage = getNip46Storage();
if (!storage) {
return;
}
const entries = {};
for (const key of Object.keys(map || {})) {
const session = sanitizeStoredNip46Session(map[key]);
if (session) {
entries[key] = session;
}
}
try {
if (!Object.keys(entries).length) {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
return;
}
storage.setItem(
NIP46_SESSIONS_MAP_STORAGE_KEY,
JSON.stringify({ version: NIP46_SESSIONS_MAP_VERSION, entries }),
);
} catch (error) {
// ignore persistence failures
}
}
// Read the legacy v1 single slot, re-persisting it in the sanitized (no
// plaintext) shape if it still carries raw credentials.
function readV1Nip46Session() {
const storage = getNip46Storage();
if (!storage) {
return null;
@@ -289,6 +377,49 @@ export function readStoredNip46Session() {
}
}
// Read a stored remote-signer session.
// readStoredNip46Session(pubkey) → that account's session (or null)
// readStoredNip46Session() → the last-connected account (v1 slot),
// for boot restore / backward-compat.
// The v1 single slot is migrated into the per-pubkey map on first read.
export function readStoredNip46Session(pubkey) {
const v1 = readV1Nip46Session();
const map = readNip46SessionsMap();
// Migrate the legacy single slot into the per-pubkey map.
if (v1 && v1.userPubkey) {
const key = normalizeNip46PubkeyKey(v1.userPubkey);
if (key && !map[key]) {
map[key] = v1;
writeNip46SessionsMap(map);
}
}
const target = normalizeNip46PubkeyKey(pubkey);
if (target) {
if (map[target]) {
return map[target];
}
if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
return v1;
}
return null;
}
// No target: the last-connected account. Fall back to the sole stored session
// if the v1 slot was cleared but the map still holds exactly one.
if (v1) {
return v1;
}
const keys = Object.keys(map);
return keys.length === 1 ? map[keys[0]] : null;
}
// User pubkeys (hex, lowercase) of every account with a stored session.
export function listStoredNip46SessionPubkeys() {
return Object.keys(readNip46SessionsMap());
}
export function writeStoredNip46SessionSync(payload) {
const storage = getNip46Storage();
if (!storage) {
@@ -321,6 +452,16 @@ export function writeStoredNip46SessionSync(payload) {
return;
}
// Store under the account's user pubkey so several saved accounts coexist…
const key = normalizeNip46PubkeyKey(normalized.userPubkey);
if (key) {
const map = readNip46SessionsMap();
map[key] = normalized;
writeNip46SessionsMap(map);
}
// …and keep the v1 single slot pointed at the last-connected account so boot
// restore and no-arg reads resolve a sensible default.
try {
storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized));
} catch (error) {
@@ -412,17 +553,45 @@ export async function decryptNip46Session(session, passphrase) {
};
}
export function clearStoredNip46Session() {
// Forget a stored session. With a pubkey, only that account is removed (the
// others stay reconnectable); without one, every stored session is wiped
// (logout).
export function clearStoredNip46Session(pubkey) {
const storage = getNip46Storage();
if (!storage) {
return;
}
const target = normalizeNip46PubkeyKey(pubkey);
if (target) {
const map = readNip46SessionsMap();
if (map[target]) {
delete map[target];
writeNip46SessionsMap(map);
}
// Drop the v1 default only if it pointed at the account we just removed.
const v1 = readV1Nip46Session();
if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) {
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
}
return;
}
try {
storage.removeItem(NIP46_SESSION_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
try {
storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY);
} catch (error) {
// ignore cleanup issues
}
}
export function decodeNpubToHex(npub) {
+21
View File
@@ -0,0 +1,21 @@
// Monotonic created_at for replaceable events (NIP-01: kind 30000 subscriptions,
// 30015/30078 preference notes, 10000 blocks, etc). Relays keep only the newest
// event per (kind, pubkey, d-tag) and reject/ignore an update whose created_at is
// not STRICTLY greater than the stored one. Two quick edits within the same
// wall-clock second otherwise collide: the second is rejected as "not newer" (the
// client shows an error) yet lands on relays that break same-created_at ties by id
// (so it "failed but works after refresh"). Always advance past the last-published
// created_at to remove the collision.
//
// @param {number} lastCreatedAt - created_at of the last event we published (0 if none).
// @param {number} [nowSeconds] - current unix seconds (injectable for tests).
// @returns {number} A created_at strictly greater than lastCreatedAt.
export function nextReplaceableCreatedAt(lastCreatedAt, nowSeconds) {
const now = Number.isFinite(nowSeconds)
? Math.floor(nowSeconds)
: Math.floor(Date.now() / 1000);
const last = Number.isFinite(lastCreatedAt) ? Math.floor(lastCreatedAt) : 0;
return Math.max(now, last + 1);
}
export default nextReplaceableCreatedAt;
+216 -5
View File
@@ -1,6 +1,13 @@
import { devLogger } from "../utils/logger.js";
export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
// Per-pubkey encrypted-key store. Follows the existing `bitvid:<thing>:<npub>`
// convention (profileCache, nwcSettings, …) so several saved nsec accounts can
// each keep their own encrypted key on the device and be switched between. The
// v1 single slot above is kept in sync as the "active/last-saved" default and is
// migrated into this map on first read.
export const SESSION_ACTORS_MAP_STORAGE_KEY = "bitvid:sessionActors:v2";
export const SESSION_ACTORS_MAP_VERSION = 2;
export const SESSION_ACTOR_ENCRYPTION_VERSION = 1;
export const SESSION_ACTOR_KDF_ITERATIONS = 250_000;
export const SESSION_ACTOR_KDF_HASH = "SHA-256";
@@ -114,7 +121,9 @@ function persistSessionActorToIndexedDb(payload) {
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
store.put(payload, SESSION_ACTOR_STORAGE_KEY);
// Key each account's payload by pubkey so multiple saved accounts don't
// clobber one another (legacy single-slot records used the storage key).
store.put(payload, normalizePubkeyKey(payload?.pubkey) || SESSION_ACTOR_STORAGE_KEY);
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB write failed"));
@@ -128,7 +137,7 @@ function persistSessionActorToIndexedDb(payload) {
});
}
function clearStoredSessionActorIndexedDb() {
function clearStoredSessionActorIndexedDb(pubkey) {
return openSessionActorDb()
.then((db) => {
if (!db) {
@@ -137,7 +146,14 @@ function clearStoredSessionActorIndexedDb() {
return new Promise((resolve, reject) => {
const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite");
const store = tx.objectStore(SESSION_ACTOR_STORE);
store.delete(SESSION_ACTOR_STORAGE_KEY);
const key = normalizePubkeyKey(pubkey);
if (key) {
// Forget just this account's key…
store.delete(key);
} else {
// …or wipe everything (logout / legacy single-slot record too).
store.clear();
}
tx.oncomplete = () => resolve(true);
tx.onerror = () =>
reject(tx.error || new Error("Session actor IndexedDB delete failed"));
@@ -351,7 +367,113 @@ export async function decryptSessionPrivateKey(payload, passphrase) {
return decoder.decode(decrypted);
}
export function readStoredSessionActorEntry() {
function normalizePubkeyKey(pubkey) {
return typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
}
// Turn a parsed stored payload into a canonical entry, or null if it lacks a
// usable encrypted key. Never exposes plaintext (privateKey is always "").
function normalizeStoredActorEntry(parsed) {
const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : "";
const privateKeyEncrypted =
typeof parsed?.privateKeyEncrypted === "string"
? parsed.privateKeyEncrypted.trim()
: "";
const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption);
const createdAt = Number.isFinite(parsed?.createdAt)
? parsed.createdAt
: Date.now();
if (!privateKeyEncrypted || !encryption) {
return null;
}
return { pubkey, privateKey: "", privateKeyEncrypted, encryption, createdAt };
}
// Read the per-pubkey map { [pubkeyLower]: entry } from localStorage.
function readSessionActorsMap() {
if (typeof localStorage === "undefined") {
return {};
}
let raw = null;
try {
raw = localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to read session actor map:", error);
return {};
}
if (!raw || typeof raw !== "string") {
return {};
}
try {
const parsed = JSON.parse(raw);
const entries =
parsed && typeof parsed === "object" ? parsed.entries : null;
if (!entries || typeof entries !== "object") {
return {};
}
const map = {};
for (const key of Object.keys(entries)) {
const entry = normalizeStoredActorEntry(entries[key]);
if (!entry) {
continue;
}
const normalizedKey = normalizePubkeyKey(entry.pubkey || key);
if (normalizedKey) {
map[normalizedKey] = entry;
}
}
return map;
} catch (error) {
devLogger.warn("[nostr] Failed to parse session actor map:", error);
try {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (cleanupError) {
devLogger.warn(
"[nostr] Failed to clear corrupt session actor map:",
cleanupError,
);
}
return {};
}
}
function writeSessionActorsMap(map) {
if (typeof localStorage === "undefined") {
return;
}
const entries = {};
for (const key of Object.keys(map || {})) {
const entry = map[key];
if (!entry || !entry.privateKeyEncrypted || !entry.encryption) {
continue;
}
entries[key] = {
pubkey: entry.pubkey || key,
privateKeyEncrypted: entry.privateKeyEncrypted,
encryption: entry.encryption,
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
};
}
try {
if (!Object.keys(entries).length) {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
return;
}
localStorage.setItem(
SESSION_ACTORS_MAP_STORAGE_KEY,
JSON.stringify({ version: SESSION_ACTORS_MAP_VERSION, entries }),
);
} catch (error) {
devLogger.warn("[nostr] Failed to persist session actor map:", error);
}
}
// Read the legacy single-slot (v1) entry. Also handles the legacy plaintext
// migration: a payload that still carries `privateKey` is re-persisted in the
// encrypted-only shape, or cleared if it can't be salvaged.
function readV1Entry() {
if (typeof localStorage === "undefined") {
return null;
}
@@ -427,6 +549,50 @@ export function readStoredSessionActorEntry() {
return null;
}
// Read a stored, passphrase-encrypted nsec entry.
// readStoredSessionActorEntry(pubkey) → that specific account's entry (or null)
// readStoredSessionActorEntry() → the active/last-saved account (v1 slot),
// for boot restore / backward-compat.
// The v1 single slot is migrated into the per-pubkey map on first read so
// existing single-account installs keep working and gain multi-account storage.
export function readStoredSessionActorEntry(pubkey) {
const v1 = readV1Entry();
const map = readSessionActorsMap();
// Migrate the legacy single slot into the per-pubkey map.
if (v1 && v1.pubkey) {
const key = normalizePubkeyKey(v1.pubkey);
if (key && !map[key]) {
map[key] = v1;
writeSessionActorsMap(map);
}
}
const target = normalizePubkeyKey(pubkey);
if (target) {
if (map[target]) {
return map[target];
}
if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
return v1;
}
return null;
}
// No target: the active/last-saved account (v1 slot). Fall back to the sole
// stored account if the v1 slot was cleared but the map still has exactly one.
if (v1) {
return v1;
}
const keys = Object.keys(map);
return keys.length === 1 ? map[keys[0]] : null;
}
// Pubkeys (hex, lowercase) of every account with an encrypted key on this device.
export function listStoredSessionActorPubkeys() {
return Object.keys(readSessionActorsMap());
}
export function persistSessionActor(actor) {
if (
!actor ||
@@ -458,6 +624,21 @@ export function persistSessionActor(actor) {
persistSessionActorToIndexedDb(payload);
// Store under the account's pubkey so several saved accounts coexist…
const key = normalizePubkeyKey(payload.pubkey);
if (key) {
const map = readSessionActorsMap();
map[key] = {
pubkey: payload.pubkey,
privateKeyEncrypted: payload.privateKeyEncrypted,
encryption: payload.encryption,
createdAt,
};
writeSessionActorsMap(map);
}
// …and keep the v1 single slot pointed at the last-saved account so boot
// restore and no-arg reads resolve a sensible default.
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(
@@ -470,7 +651,32 @@ export function persistSessionActor(actor) {
}
}
export function clearStoredSessionActor() {
// Forget a stored key. With a pubkey, only that account is removed (the others
// stay switchable); without one, every stored account is wiped (logout).
export function clearStoredSessionActor(pubkey) {
const target = normalizePubkeyKey(pubkey);
if (target) {
clearStoredSessionActorIndexedDb(target);
const map = readSessionActorsMap();
if (map[target]) {
delete map[target];
writeSessionActorsMap(map);
}
// Drop the v1 default only if it pointed at the account we just removed.
if (typeof localStorage !== "undefined") {
const v1 = readV1Entry();
if (v1 && normalizePubkeyKey(v1.pubkey) === target) {
try {
localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
}
}
return;
}
clearStoredSessionActorIndexedDb();
if (typeof localStorage !== "undefined") {
@@ -479,6 +685,11 @@ export function clearStoredSessionActor() {
} catch (error) {
devLogger.warn("[nostr] Failed to clear stored session actor:", error);
}
try {
localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY);
} catch (error) {
devLogger.warn("[nostr] Failed to clear session actor map:", error);
}
}
}
+184
View File
@@ -0,0 +1,184 @@
// Opt-in "keep unlocked" cache for a decrypted nsec key so the user isn't
// re-prompted for their PIN after every page reload (TODO #51). SECURITY TRADEOFF:
// while an unlocked key is cached it is stored DECRYPTED — the passphrase no longer
// protects it at rest. Two tiers, chosen by the user at unlock time:
// - session (default): sessionStorage — survives refresh/navigation, auto-cleared
// when the tab/browser closes. Roughly the same exposure as the in-memory signer.
// - persistent (opt-in checkbox + one-time warning): also localStorage — survives
// until the user clears site data. Convenient, but the key sits on disk.
//
// readUnlockedKey transparently promotes a persistent copy back into the session
// store so subsequent reads are cheap. Everything is keyed per pubkey.
const STORE_KEY = "bitvid:unlockedKeys:v1";
const HEX64 = /^[0-9a-f]{64}$/;
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
}
function getStore(kind) {
try {
const store =
kind === "session"
? globalThis.sessionStorage
: globalThis.localStorage;
if (
store &&
typeof store.getItem === "function" &&
typeof store.setItem === "function" &&
typeof store.removeItem === "function"
) {
return store;
}
} catch (error) {
// Access can throw (privacy mode, disabled storage) — treat as unavailable.
}
return null;
}
function readMap(store) {
if (!store) {
return {};
}
try {
const raw = store.getItem(STORE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
function writeMap(store, map) {
if (!store) {
return;
}
try {
const keys = Object.keys(map || {});
if (!keys.length) {
store.removeItem(STORE_KEY);
return;
}
store.setItem(STORE_KEY, JSON.stringify(map));
} catch (error) {
// Quota / disabled storage — best-effort only.
}
}
/**
* Cache a decrypted nsec key so it can be restored without the passphrase.
* Always writes the session tier; the persistent tier is written only when
* `persist` is true (and any stale persistent copy is removed otherwise).
*
* @returns {boolean} true if a valid key was cached.
*/
export function rememberUnlockedKey(pubkey, privateKeyHex, { persist = false } = {}) {
const key = normalizePubkey(pubkey);
const value =
typeof privateKeyHex === "string" ? privateKeyHex.trim().toLowerCase() : "";
if (!key || !HEX64.test(value)) {
return false;
}
const session = getStore("session");
if (session) {
const map = readMap(session);
map[key] = value;
writeMap(session, map);
}
const local = getStore("local");
if (local) {
const map = readMap(local);
if (persist) {
map[key] = value;
} else {
delete map[key];
}
writeMap(local, map);
}
return true;
}
/**
* Read a cached decrypted key for a pubkey (session first, then persistent). A
* persistent hit is promoted back into the session store.
*
* @returns {string} the hex private key, or "" if none is cached.
*/
export function readUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return "";
}
const session = getStore("session");
const sessionValue = readMap(session)[key];
if (typeof sessionValue === "string" && HEX64.test(sessionValue)) {
return sessionValue;
}
const local = getStore("local");
const localValue = readMap(local)[key];
if (typeof localValue === "string" && HEX64.test(localValue)) {
// Warm the session tier so later reads don't touch the persistent store.
if (session) {
const map = readMap(session);
map[key] = localValue;
writeMap(session, map);
}
return localValue;
}
return "";
}
/** True if this pubkey has a persistent (on-disk) cached key. */
export function hasPersistentUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return false;
}
const value = readMap(getStore("local"))[key];
return typeof value === "string" && HEX64.test(value);
}
/** Forget the cached key for one pubkey (both tiers). */
export function forgetUnlockedKey(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return;
}
for (const kind of ["session", "local"]) {
const store = getStore(kind);
if (!store) {
continue;
}
const map = readMap(store);
if (key in map) {
delete map[key];
writeMap(store, map);
}
}
}
/** Forget every cached key (both tiers). Used on full logout / lock-all. */
export function clearAllUnlockedKeys() {
for (const kind of ["session", "local"]) {
const store = getStore(kind);
if (store) {
try {
store.removeItem(STORE_KEY);
} catch (error) {
// best-effort
}
}
}
}
export const UNLOCKED_KEY_STORE_KEY = STORE_KEY;
+42
View File
@@ -335,6 +335,48 @@ export function describePublishOutcome({ accepted = 0, total = 0 } = {}) {
};
}
// A RelayPublishError whose per-relay failures are ALL "publish timeout" means the
// event was sent but not ACKed in the window — NOT that any relay rejected it. For
// an idempotent, replaceable event this almost always persisted (users see it
// after a refresh), so callers that publish such events can treat this as a soft
// success instead of a hard error. An explicit rejection has a different reason
// and returns false, so it still fails loudly.
export function relayPublishFailuresAreAllTimeouts(error) {
const failures = Array.isArray(error?.relayFailures) ? error.relayFailures : [];
if (!failures.length) {
return false;
}
return failures.every((failure) => {
const reason = typeof failure?.reason === "string" ? failure.reason : "";
return /timeout/i.test(reason);
});
}
// Like assertAnyRelayAccepted, but for idempotent/replaceable publishes: when the
// only reason nothing was accepted is that every relay timed out (unconfirmed),
// don't throw — return a summary flagged `{ unconfirmed: true }` so the caller can
// treat it as an optimistic success (reconciles on next load). An explicit relay
// rejection (or an empty/other failure set) still throws exactly as before.
export function assertAnyRelayAcceptedOrUnconfirmed(results = [], options = {}) {
try {
const summary = assertAnyRelayAccepted(results, options);
return { ...summary, unconfirmed: false };
} catch (error) {
if (error instanceof RelayPublishError && relayPublishFailuresAreAllTimeouts(error)) {
return {
accepted: [],
failed: (error.relayFailures || []).map((f) => ({
url: f.url,
error: f.error || null,
reason: f.reason,
})),
unconfirmed: true,
};
}
throw error;
}
}
export function assertAnyRelayAccepted(results = [], options = {}) {
const summary = summarizePublishResults(results);
if (summary.accepted.length > 0) {
+12 -19
View File
@@ -9,7 +9,7 @@ import { buildRelayListEvent } from "./nostrEventSchemas.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
assertAnyRelayAcceptedOrUnconfirmed,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
@@ -812,24 +812,17 @@ class RelayPreferencesManager {
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "relay preferences update",
message: "No relays accepted the update.",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
// An all-timeout (unconfirmed) publish of the replaceable relay list is a soft
// success — sent + almost always persisted; don't error/revert. Explicit
// rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "relay preferences update",
message: "No relays accepted the update.",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
"[RelayPreferencesManager] Relay list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
if (publishSummary.failed.length) {
+8 -1
View File
@@ -97,6 +97,10 @@ function normalizeOptions(options) {
handshakeTimeoutMs: Number.isFinite(options.handshakeTimeoutMs)
? Number(options.handshakeTimeoutMs)
: undefined,
// Set when switching to a specific saved account, so reusing a stored
// session targets that account rather than the last-connected default.
expectPubkey:
typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "",
};
}
@@ -163,7 +167,10 @@ export default {
// For now, I will assume I will fix client.js later or accept that stored sessions are trusted?
// No, I must fix useStoredRemoteSigner too.
result = await nostrClient.useStoredRemoteSigner({ validator });
result = await nostrClient.useStoredRemoteSigner({
validator,
pubkey: normalized.expectPubkey || undefined,
});
devLogger.debug("[nip46] Stored remote signer returned", {
pubkey: summarizeHexForLog(normalizePubkey(result)),
});
+5 -1
View File
@@ -60,6 +60,10 @@ function normalizeOptions(options) {
persist: options.persist === true,
passphrase: typeof options.passphrase === "string" ? options.passphrase : "",
unlockStored: options.unlockStored === true,
// Set when switching to a specific saved account, so the stored-key unlock
// targets that account rather than the last-saved default.
expectPubkey:
typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "",
};
}
@@ -120,7 +124,7 @@ export default {
const unlockResult = await nostrClient.unlockStoredSessionActor(
suppliedPassphrase,
{ validator },
{ validator, pubkey: normalized.expectPubkey || undefined },
);
const unlockedPubkey = normalizePubkey(unlockResult);
+26
View File
@@ -248,6 +248,18 @@ export default class AuthService {
return { pubkey: null, providerId, authType: detailAuthType, signer };
}
// Only switchProfile sets expectPubkey: don't activate a DIFFERENT account than the
// one requested (e.g. a single-slot stored nsec/NIP-46 session resolving to another
// pubkey). Reject instead of silently switching to the wrong profile.
const expectPubkey = this.normalizeHexPubkey(providerOptions?.expectPubkey);
if (expectPubkey && this.normalizeHexPubkey(trimmed) !== expectPubkey) {
const mismatchError = new Error(
"Signed in as a different account than the one selected.",
);
mismatchError.code = "pubkey-mismatch";
throw mismatchError;
}
if (autoApply === false) {
return { pubkey: trimmed, providerId, authType: detailAuthType, signer };
}
@@ -992,6 +1004,20 @@ export default class AuthService {
expectPubkey: normalizedTarget,
persistActive: true,
...(providerId ? { providerId } : {}),
// Switching to a saved nsec account: unlock its stored key with the passphrase
// the caller collected (the private key can't be restored otherwise).
...(normalizedOptions.unlockStored
? {
unlockStored: true,
passphrase:
typeof normalizedOptions.passphrase === "string"
? normalizedOptions.passphrase
: "",
}
: {}),
// Switching to a saved NIP-46 account: reuse the stored remote-signer session
// instead of starting a fresh handshake (which would need a new connect URI/QR).
...(normalizedOptions.reuseStored ? { reuseStored: true } : {}),
};
const autoApply = requestOptions.autoApply !== false;
+126
View File
@@ -0,0 +1,126 @@
// One encrypted cross-login sync implementation, shared by every syncable item
// (NWC wallet, storage credentials, …). Previously walletSyncService and
// storageSyncService were near-identical copies; the only per-item differences
// are the d-tag, the sync "kind", the availability check, and how the payload is
// built (push) / applied (pull). This factory captures the shared machinery —
// per-pubkey enable flags, conflict-checked push, cheap remote existence check,
// enable/disable — so the two "systems" become one.
//
// Item config:
// id "wallet" | "storage" — the per-pubkey sync-flag kind
// dtag stable d-tag for the encrypted note
// encryptedSync the encrypted-sync facade
// available () => boolean (extra availability beyond encryptedSync)
// buildPayload async (pubkey) => payload | null (null => "nothing-to-sync")
// applyPayload async (pubkey, payload) => void | { imported:false, error }
// logLabel short label for warnings
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
export function createEncryptedSyncItem({
id,
dtag,
encryptedSync = defaultEncryptedSync,
available = () => true,
buildPayload,
applyPayload,
logLabel = id || "sync",
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.()) && Boolean(available());
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), id);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), id, enabled);
}
// Cheap existence check (no decrypt) for the login-restore offer.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(dtag);
return Boolean(result?.exists);
}
async function push(pubkey, { confirmOverwrite } = {}) {
const key = normalizePubkey(pubkey);
if (!key) {
return { ok: false, error: "missing-pubkey" };
}
let payload;
try {
payload = await buildPayload(key);
} catch (error) {
userLogger.warn(`[${logLabel}] Failed to build sync payload:`, error);
return { ok: false, error: "export-failed", cause: error };
}
if (!payload) {
return { ok: false, error: "nothing-to-sync" };
}
return pushWithConflictCheck({
encryptedSync,
dTag: dtag,
kind: id,
pubkey: key,
payload,
confirmOverwrite,
});
}
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(dtag);
if (!result?.found) {
return result;
}
try {
const applied = await applyPayload(key, result.payload || {});
if (applied && applied.imported === false) {
return { ...result, imported: false, error: applied.error };
}
} catch (error) {
userLogger.warn(`[${logLabel}] Failed to apply pulled settings:`, error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
}
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(dtag);
}
return {
id,
dtag,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
disable,
};
}
export default createEncryptedSyncItem;
+18 -3
View File
@@ -5,6 +5,7 @@ import {
getActiveSigner,
} from "../nostrClientFacade.js";
import { isSessionActor } from "../nostr/sessionActor.js";
import { nextReplaceableCreatedAt } from "../nostr/replaceableCreatedAt.js";
import {
buildHashtagPreferenceEvent,
getNostrEventSchema,
@@ -12,7 +13,7 @@ import {
} from "../nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
assertAnyRelayAcceptedOrUnconfirmed,
} from "../nostrPublish.js";
import { userLogger, devLogger } from "../utils/logger.js";
import {
@@ -1408,7 +1409,13 @@ class HashtagPreferencesService {
throw error;
}
const createdAt = Math.floor(Date.now() / 1000);
// Replaceable event: created_at MUST be strictly greater than the last one we
// published, or relays reject the update as "not newer" and keep the old copy.
// Two quick edits (e.g. adding a second disinterest within the same wall-clock
// second) otherwise collide — the second errored on the client yet landed on
// relays that resolve same-created_at ties by id, so it "failed but works after
// refresh". Monotonic created_at removes the collision entirely.
const createdAt = nextReplaceableCreatedAt(this.eventCreatedAt);
const event = buildHashtagPreferenceEvent({
pubkey: targetPubkey,
created_at: createdAt,
@@ -1453,9 +1460,17 @@ class HashtagPreferencesService {
signedEvent,
);
const publishSummary = assertAnyRelayAccepted(publishResults, {
// All-timeout (unconfirmed) publish of the replaceable preferences list is a
// soft success — sent + almost always persisted; don't throw. Explicit
// rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "hashtag-preferences",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
`${LOG_PREFIX} Preferences not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).`,
);
}
if (publishSummary.failed?.length) {
publishSummary.failed.forEach(({ url, error }) => {
+18
View File
@@ -1575,6 +1575,24 @@ export class NostrService {
return this.filterVideos(all, options);
}
// Optimistically insert a just-published video into the shared client caches
// so the feed can render it before it round-trips through the relays. Keeps
// the author index in sync so channel/subscription grids reflect it too.
ingestLocalVideoEvent(rawEvent) {
if (
!this.nostrClient ||
typeof this.nostrClient.ingestLocalVideoEvent !== "function"
) {
return null;
}
const video = this.nostrClient.ingestLocalVideoEvent(rawEvent);
if (video && !video.deleted && !video.invalid) {
this.markAuthorIndexDirty();
this.emit("videos:updated", { videos: null, reason: "local-publish" });
}
return video;
}
getActiveVideosByAuthors(authors = [], options = {}) {
const candidates = ensureSet(authors);
const normalizedAuthors = new Set();
+9 -5
View File
@@ -6,6 +6,8 @@
// if the user accepts.
import { storageSyncService as defaultStorageSync } from "./storageSyncService.js";
import { showConfirm } from "../ui/confirmDialog.js";
import { setSyncEnabled } from "./settingsSyncFlags.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
@@ -63,10 +65,7 @@ const ITEM_LABELS = {
export function createSettingsRestorePrompt({
storageSync = defaultStorageSync,
walletSync = null,
confirm = (message) =>
typeof window !== "undefined" && typeof window.confirm === "function"
? window.confirm(message)
: false,
confirm = (message) => showConfirm(message, { confirmLabel: "Restore" }),
logger = null,
} = {}) {
// Items not already enabled / not already offered on THIS device that have a
@@ -117,7 +116,7 @@ export function createSettingsRestorePrompt({
}
const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and ");
const accepted = confirm(
const accepted = await confirm(
`Found an encrypted copy of your ${list} on your Nostr account. ` +
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
);
@@ -132,6 +131,11 @@ export function createSettingsRestorePrompt({
const result = await serviceByKind[kind].pull(key);
if (result?.imported) {
restored.push(kind);
// Accepting the offer means "keep this device in sync", not just a
// one-time pull — enable the flag so future saves auto-push (the toggle
// then reflects reality). Without this, users restored once but never
// synced again ("not syncing").
setSyncEnabled(key, kind, true);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} pull failed`, error);
+17
View File
@@ -526,6 +526,23 @@ export class StorageService {
return this.masterKeys.has(pubkey);
}
/**
* True if an encrypted storage account already exists for this pubkey. Lets
* callers auto-unlock existing storage WITHOUT unlock() creating a brand-new
* account for users who never set storage up (e.g. magnet/URL-only uploaders).
*/
async hasStoredAccount(pubkey) {
if (!pubkey) {
return false;
}
try {
const account = await this._getAccount(pubkey);
return Boolean(account && account.encryptedMasterKey);
} catch (error) {
return false;
}
}
/**
* Saves a connection configuration.
* Requires unlock() to be called first.
+17 -96
View File
@@ -9,9 +9,7 @@
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import defaultStorageService from "./storageService.js";
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
import { createEncryptedSyncItem } from "./encryptedSyncItem.js";
export const STORAGE_SYNC_DTAG = "bitvid:storage-connections";
const SYNC_KIND = "storage";
@@ -20,100 +18,23 @@ export function createStorageSyncService({
encryptedSync = defaultEncryptedSync,
storage = defaultStorageService,
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
const item = createEncryptedSyncItem({
id: SYNC_KIND,
dtag: STORAGE_SYNC_DTAG,
encryptedSync,
logLabel: "storageSync",
// Export the local storage account record (self-protected). null => nothing
// to sync. An export error surfaces as { error: "export-failed" }.
buildPayload: async (pubkey) => {
const record = await storage.exportAccountRecord(pubkey);
return record || null;
},
applyPayload: async (pubkey, payload) => {
await storage.importAccountRecord(pubkey, payload);
},
});
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.());
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
}
// Cheap check (no decrypt) for whether a synced note exists — for the
// login-restore prompt.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(STORAGE_SYNC_DTAG);
return Boolean(result?.exists);
}
// Push the current local storage account record to the user's encrypted note.
async function push(pubkey, { confirmOverwrite } = {}) {
const key = normalizePubkey(pubkey);
if (!key) {
return { ok: false, error: "missing-pubkey" };
}
let record;
try {
record = await storage.exportAccountRecord(key);
} catch (error) {
userLogger.warn("[storageSync] Failed to export storage record:", error);
return { ok: false, error: "export-failed", cause: error };
}
if (!record) {
return { ok: false, error: "nothing-to-sync" };
}
return pushWithConflictCheck({
encryptedSync,
dTag: STORAGE_SYNC_DTAG,
kind: SYNC_KIND,
pubkey: key,
payload: record,
confirmOverwrite,
});
}
// Pull the encrypted note and import it into local storage. Returns the
// encrypted-sync pull result with `imported: true` when a record was applied.
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(STORAGE_SYNC_DTAG);
if (!result?.found) {
return result;
}
try {
await storage.importAccountRecord(key, result.payload);
} catch (error) {
userLogger.warn("[storageSync] Failed to import storage record:", error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
// Turn sync on: remember the choice and push immediately.
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
}
// Turn sync off: remember the choice and wipe the published note.
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(STORAGE_SYNC_DTAG);
}
return {
STORAGE_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
disable,
};
return { STORAGE_SYNC_DTAG, ...item };
}
export const storageSyncService = createStorageSyncService();
+29 -100
View File
@@ -11,9 +11,7 @@
// factory — the caller injects the live service.
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
import { createEncryptedSyncItem } from "./encryptedSyncItem.js";
export const WALLET_SYNC_DTAG = "bitvid:nwc";
const SYNC_KIND = "wallet";
@@ -22,109 +20,40 @@ export function createWalletSyncService({
encryptedSync = defaultEncryptedSync,
nwcSettings,
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.()) && Boolean(nwcSettings);
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
}
// Build the minimal syncable payload from current wallet settings.
function buildPayload() {
const settings = nwcSettings?.getActiveNwcSettings?.() || {};
const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : "";
const payload = { nwcUri };
if (settings.defaultZap !== undefined) {
payload.defaultZap = settings.defaultZap;
}
return payload;
}
// Cheap check (no decrypt) for whether a synced note exists — for the
// login-restore prompt.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(WALLET_SYNC_DTAG);
return Boolean(result?.exists);
}
async function push(pubkey, { confirmOverwrite } = {}) {
const key = normalizePubkey(pubkey);
if (!key) {
return { ok: false, error: "missing-pubkey" };
}
const payload = buildPayload();
if (!payload.nwcUri) {
return { ok: false, error: "nothing-to-sync" };
}
return pushWithConflictCheck({
encryptedSync,
dTag: WALLET_SYNC_DTAG,
kind: SYNC_KIND,
pubkey: key,
payload,
confirmOverwrite,
});
}
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(WALLET_SYNC_DTAG);
if (!result?.found) {
return result;
}
const payload = result.payload || {};
const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : "";
if (!nwcUri) {
return { ...result, imported: false, error: "empty-payload" };
}
try {
const item = createEncryptedSyncItem({
id: SYNC_KIND,
dtag: WALLET_SYNC_DTAG,
encryptedSync,
logLabel: "walletSync",
available: () => Boolean(nwcSettings),
// Minimal syncable payload from current wallet settings. null => nothing to
// sync (no wallet connected).
buildPayload: () => {
const settings = nwcSettings?.getActiveNwcSettings?.() || {};
const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : "";
if (!nwcUri) {
return null;
}
const payload = { nwcUri };
if (settings.defaultZap !== undefined) {
payload.defaultZap = settings.defaultZap;
}
return payload;
},
applyPayload: async (pubkey, payload) => {
const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : "";
if (!nwcUri) {
return { imported: false, error: "empty-payload" };
}
const partial = { nwcUri };
if (payload.defaultZap !== undefined) {
partial.defaultZap = payload.defaultZap;
}
await nwcSettings.updateActiveNwcSettings(partial);
} catch (error) {
userLogger.warn("[walletSync] Failed to apply pulled wallet settings:", error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
},
});
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
}
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(WALLET_SYNC_DTAG);
}
return {
WALLET_SYNC_DTAG,
isAvailable,
isEnabled,
hasRemote,
push,
pull,
enable,
disable,
};
return { WALLET_SYNC_DTAG, ...item };
}
export default createWalletSyncService;
+23 -21
View File
@@ -21,7 +21,7 @@ import {
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import {
publishEventToRelays,
assertAnyRelayAccepted
assertAnyRelayAcceptedOrUnconfirmed,
} from "./nostrPublish.js";
import { getApplication } from "./applicationContext.js";
import { VideoListView } from "./ui/views/VideoListView.js";
@@ -1452,26 +1452,22 @@ class SubscriptionsManager {
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "subscription list"
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
// Only an explicit relay rejection (or a pre-publish signer/encryption error,
// thrown earlier) is a real failure. A result where every relay merely timed
// out means the event was sent to an idempotent, replaceable list and almost
// certainly persisted — treated as a soft success (unconfirmed) so the
// optimistic UI is NOT reverted and the user doesn't see a spurious error.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "subscription list",
});
const softSuccess = publishSummary.unconfirmed === true;
if (softSuccess) {
userLogger.warn(
"[SubscriptionsManager] Subscription list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
if (publishSummary.failed.length) {
if (publishSummary?.failed?.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
@@ -1486,12 +1482,18 @@ class SubscriptionsManager {
});
}
// Record the published event id for both a confirmed and an
// optimistic/unconfirmed publish — the event carries this id regardless.
this.subsEventId = signedEvent.id;
this.subsEventCreatedAt = signedEvent.created_at;
this.saveToCache(userPubkey);
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
const acceptedUrls = publishSummary
? publishSummary.accepted.map(({ url }) => url)
: [];
devLogger.log(
"Subscription list published, event id:",
softSuccess
? "Subscription list published (optimistic/unconfirmed), event id:"
: "Subscription list published, event id:",
signedEvent.id,
"accepted relays:",
acceptedUrls
+34
View File
@@ -0,0 +1,34 @@
// Single import surface for app notifications, so every alert/confirm goes through the
// site's notification system instead of native dialogs.
//
// - notifyError / notifyStatus / notifySuccess → the app's toast banners
// (NotificationController) for components/services without a local showError.
// - showConfirm → the styled promise-based confirm dialog (replaces window.confirm).
//
// Components that already receive a showError/showSuccess callback should keep using it;
// this is for modules that don't (e.g. standalone controllers/services).
import { getApplication } from "../applicationContext.js";
import { showConfirm } from "./confirmDialog.js";
function app() {
try {
return getApplication();
} catch (error) {
return null;
}
}
export function notifyError(message) {
app()?.showError?.(message);
}
export function notifyStatus(message, options) {
app()?.showStatus?.(message, options);
}
export function notifySuccess(message) {
app()?.showSuccess?.(message);
}
export { showConfirm };
+9
View File
@@ -572,6 +572,15 @@ export default class ApplicationBootstrap {
},
describeLoginError: (error, fallbackMessage) =>
app.describeLoginError(error, fallbackMessage),
// Ensure the active signer can encrypt/sign before a list mutation; if a
// reloaded nsec session lost its in-memory key, this prompts once to
// re-unlock it instead of dead-ending with a blanket error (TODO #57).
ensureEncryptionCapableSigner: (options = {}) =>
app.ensureEncryptionCapableSigner(options),
// "Keep unlocked" lock controls (TODO #51).
isSessionKeptUnlocked: (pubkey) => app.isSessionKeptUnlocked(pubkey),
lockKeptUnlockedSession: (pubkey) =>
app.lockKeptUnlockedSession(pubkey),
hashtagPreferences: app.hashtagPreferences,
getHashtagPreferences: () => app.getHashtagPreferences(),
describeHashtagPreferencesError: (error, fallbackMessage) =>
+172 -12
View File
@@ -26,6 +26,7 @@ import {
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -54,6 +55,7 @@ export class UploadModal {
container,
onRequestStorageSettings,
onRequestUnlock,
getHashtagSuggestions,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
@@ -74,6 +76,8 @@ export class UploadModal {
typeof showSuccess === "function" ? showSuccess : () => {};
this.getCurrentPubkey =
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.getHashtagSuggestions =
typeof getHashtagSuggestions === "function" ? getHashtagSuggestions : null;
this.safeEncodeNpub =
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
// Shared storage+torrent upload core (also used by the Edit modal).
@@ -191,6 +195,7 @@ export class UploadModal {
// Initial State
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
await this.maybeAutoUnlockStorage(pubkey);
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
await this.loadFromStorage();
}
@@ -214,6 +219,8 @@ export class UploadModal {
this.submitButton = $("#btn-submit");
this.submitStatus = $("#submit-status");
this.closeButton = $("#closeUploadModal");
this.hashtagSuggestionsWrap = $("#hashtag-suggestions-wrap");
this.hashtagSuggestionsList = $("#hashtag-suggestions");
// Mode Switchers
this.modeButtons = {
@@ -518,12 +525,12 @@ export class UploadModal {
void this.captureVideoMetadata(file);
if (!this.storageConfigured) {
alert("Please configure storage before selecting a file.");
this.showError("Please configure storage before selecting a file.");
e.target.value = ""; // Clear selection
return;
}
if (!this.isStorageUnlocked) {
alert("Please unlock storage before selecting a file.");
this.showError("Please unlock storage before selecting a file.");
e.target.value = "";
return;
}
@@ -614,7 +621,7 @@ export class UploadModal {
if (this.results.magnet) this.results.magnet.value = "Upload Failed";
if (this.results.torrentUrl) this.results.torrentUrl.value = "Upload Failed";
alert(`Upload failed: ${err.message}`);
this.showError(`Upload failed: ${err.message}`);
if (this.inputs.file) this.inputs.file.value = ""; // Reset
}
@@ -676,7 +683,7 @@ export class UploadModal {
userLogger.error("Thumbnail upload failed:", err);
this.thumbnailUploadState.status = 'error';
this.updateThumbnailProgress(null, "Failed.");
alert("Thumbnail upload failed.");
this.showError("Thumbnail upload failed.");
}
}
@@ -712,6 +719,7 @@ export class UploadModal {
async refreshState() {
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
await this.maybeAutoUnlockStorage(pubkey);
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
@@ -719,6 +727,154 @@ export class UploadModal {
}
}
// Silently unlock existing storage when a decrypt-capable signer is already
// available (e.g. a kept-unlocked nsec restored on refresh, TODO #51) so the
// upload modal opens Unlocked instead of Locked. Guarded on an EXISTING account
// so it never creates storage for magnet/URL-only uploaders, and on the signer
// already being present so it never triggers a fresh permission prompt.
async maybeAutoUnlockStorage(pubkey) {
if (!this.storageService || !pubkey) {
return;
}
if (this.storageService.isUnlocked(pubkey)) {
return;
}
const signer = getActiveSigner();
if (
!signer ||
(typeof signer.nip44Decrypt !== "function" &&
typeof signer.nip04Decrypt !== "function")
) {
return;
}
try {
const hasAccount =
typeof this.storageService.hasStoredAccount === "function"
? await this.storageService.hasStoredAccount(pubkey)
: false;
if (!hasAccount) {
return;
}
await this.storageService.unlock(pubkey, { signer });
} catch (error) {
devLogger?.log?.(
"[UploadModal] Auto-unlock storage skipped:",
error?.message || error,
);
}
}
// --- Hashtag suggestion chips (TODO #45) ---
// The hashtags currently entered in the "t" repeater (normalized).
getSelectedHashtags() {
if (!this.nip71FormManager) {
return [];
}
try {
return this.nip71FormManager
.collectRepeaterValues("main", "t", (entry) =>
this.nip71FormManager.getFieldValue(entry, "value"),
)
.map((value) => this.nip71FormManager.sanitizeHashtagValue(value))
.filter(Boolean);
} catch (error) {
return [];
}
}
// Add a hashtag to the "t" repeater (skips duplicates). Returns true if added.
addHashtagValue(rawValue) {
if (!this.nip71FormManager) {
return false;
}
const normalized = this.nip71FormManager.sanitizeHashtagValue(rawValue);
if (!normalized) {
return false;
}
if (new Set(this.getSelectedHashtags()).has(normalized)) {
return false;
}
const entry = this.nip71FormManager.addRepeaterEntry("main", "t");
if (!entry) {
return false;
}
this.nip71FormManager.setFieldValue(entry, "value", normalized);
return true;
}
// Render one-tap chips for the user's most-used past hashtags. Hidden when the
// user has none (new/first upload).
renderHashtagSuggestions() {
const list = this.hashtagSuggestionsList;
const wrap = this.hashtagSuggestionsWrap;
if (!(list instanceof HTMLElement)) {
return;
}
let suggestions = [];
if (typeof this.getHashtagSuggestions === "function") {
try {
suggestions = this.getHashtagSuggestions({ limit: 12 }) || [];
} catch (error) {
suggestions = [];
}
}
list.textContent = "";
const usable = Array.isArray(suggestions) ? suggestions : [];
if (!usable.length) {
if (wrap instanceof HTMLElement) {
wrap.classList.add("hidden");
}
return;
}
for (const item of usable) {
const tag = this.nip71FormManager
? this.nip71FormManager.sanitizeHashtagValue(item?.tag)
: typeof item?.tag === "string"
? item.tag
: "";
if (!tag) {
continue;
}
const chip = document.createElement("button");
chip.type = "button";
chip.className =
"btn-ghost px-3 py-1 text-xs bg-surface border border-border/50 rounded-full";
chip.dataset.tag = tag;
chip.textContent = `#${tag}`;
chip.addEventListener("click", () => {
this.addHashtagValue(tag);
this.refreshHashtagSuggestionStates();
});
list.appendChild(chip);
}
if (wrap instanceof HTMLElement) {
wrap.classList.remove("hidden");
}
this.refreshHashtagSuggestionStates();
}
// Dim + disable chips whose tag is already in the current tag list.
refreshHashtagSuggestionStates() {
const list = this.hashtagSuggestionsList;
if (!(list instanceof HTMLElement)) {
return;
}
const selected = new Set(this.getSelectedHashtags());
list.querySelectorAll("[data-tag]").forEach((chip) => {
const isSelected = selected.has(chip.dataset.tag);
chip.classList.toggle("opacity-40", isSelected);
chip.setAttribute("aria-pressed", isSelected ? "true" : "false");
if ("disabled" in chip) {
chip.disabled = isSelected;
}
});
}
async loadFromStorage() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
@@ -824,7 +980,7 @@ export class UploadModal {
if (this.promptStoredNsecUnlock(pubkey)) {
return;
}
alert("No signer available to unlock storage.");
this.showError("No signer available to unlock storage.");
return;
}
@@ -832,7 +988,7 @@ export class UploadModal {
if (signer?.type === "extension" || signer?.type === "nip07") {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
alert("Extension permissions are required to unlock storage.");
this.showError("Extension permissions are required to unlock storage.");
return;
}
}
@@ -846,7 +1002,7 @@ export class UploadModal {
await this.loadFromStorage();
} catch (err) {
userLogger.error("Unlock failed", err);
alert("Failed to unlock storage: " + err.message);
this.showError("Failed to unlock storage: " + err.message);
} finally {
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlock";
@@ -945,17 +1101,17 @@ export class UploadModal {
// Check upload state
if (this.activeSource === "upload") {
if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') {
alert("Please wait for uploads to complete.");
this.showError("Please wait for uploads to complete.");
return;
}
if (this.videoUploadState.status === 'error') {
alert("Video upload failed. Please try again.");
this.showError("Video upload failed. Please try again.");
return;
}
if (this.videoUploadState.status !== 'complete') {
// Fallback: If no file selected, maybe they want to submit without a new file?
// (Not supported in this simplified modal, assume file required)
alert("Please select a video file and wait for it to upload.");
this.showError("Please select a video file and wait for it to upload.");
return;
}
}
@@ -1104,7 +1260,7 @@ export class UploadModal {
}
if (!hasUrl && !hasImeta && hasMagnet) {
if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return;
if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return;
}
await this.publish(metadata);
@@ -1221,9 +1377,11 @@ export class UploadModal {
this.setGlobalModalState("upload", true);
this.isVisible = true;
// Refresh lock state on open
// Refresh lock state on open. Auto-unlock first so a kept-unlocked nsec
// (restored on refresh) opens Unlocked instead of Locked (TODO #51).
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
await this.maybeAutoUnlockStorage(pubkey);
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
// Always attempt load to refresh configuration status (even if locked)
@@ -1232,6 +1390,8 @@ export class UploadModal {
});
}
this.renderHashtagSuggestions();
this.modalAccessibility?.activate({ triggerElement });
}
+108
View File
@@ -0,0 +1,108 @@
// Promise-based confirm dialog rendered with the site's modal styling, so yes/no
// confirmations live in the app's UI system instead of a native window.confirm.
//
// Importable anywhere (components AND services) — it builds its own
// `bv-modal modal-always-on-top` overlay, so it stacks above any open modal and is
// keyboard/click dismissible. Returns a Promise<boolean>: true = confirmed.
function makeButton(label, { ghost = false, danger = false } = {}) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = ghost ? "btn-ghost focus-ring" : "btn focus-ring";
if (danger) {
btn.setAttribute("data-variant", "danger");
}
btn.textContent = label;
return btn;
}
export function showConfirm(message, options = {}) {
const {
title = "",
confirmLabel = "Confirm",
cancelLabel = "Cancel",
danger = false,
} = options || {};
// No DOM (tests without jsdom / SSR): fall back to window.confirm, else decline.
if (typeof document === "undefined" || !document.body) {
if (typeof window !== "undefined" && typeof window.confirm === "function") {
return Promise.resolve(window.confirm(String(message || "")));
}
return Promise.resolve(false);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5";
const text = document.createElement("p");
text.className = "text-sm text-muted-strong whitespace-pre-line";
text.textContent = String(message || "");
body.appendChild(text);
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = makeButton(cancelLabel, { ghost: true });
const confirmBtn = makeButton(confirmLabel, { danger });
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(false);
} else if (event.key === "Enter" && document.activeElement === confirmBtn) {
cleanup(true);
}
}
cancelBtn.addEventListener("click", () => cleanup(false));
backdrop.addEventListener("click", () => cleanup(false));
confirmBtn.addEventListener("click", () => cleanup(true));
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
// Focus the safe action by default (Cancel for destructive prompts).
(danger ? cancelBtn : confirmBtn).focus();
});
}
export default showConfirm;
+6
View File
@@ -48,6 +48,12 @@ export function initUploadModal({
showSuccess,
getCurrentPubkey,
safeEncodeNpub,
// Frequency-ranked hashtags from the user's own past uploads → one-tap
// suggestion chips in the upload modal (TODO #45).
getHashtagSuggestions: (options) =>
typeof app?.getUserHashtagSuggestions === "function"
? app.getUserHashtagSuggestions(options)
: [],
eventTarget: events,
container,
onRequestStorageSettings: () => {
+15 -12
View File
@@ -6,6 +6,7 @@ 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 { showConfirm } from "../confirmDialog.js";
import {
isMirrorEnabled,
setMirrorEnabled,
@@ -282,10 +283,13 @@ export class MyVideosController {
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") {
return;
}
if (typeof window !== "undefined" && typeof window.confirm === "function") {
if (!window.confirm(`Permanently delete this file from your bucket?\n\n${key}`)) {
return;
}
if (
!(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, {
confirmLabel: "Delete",
danger: true,
}))
) {
return;
}
let result;
try {
@@ -314,14 +318,13 @@ export class MyVideosController {
if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) {
return;
}
if (typeof window !== "undefined" && typeof window.confirm === "function") {
if (
!window.confirm(
`Permanently delete all ${keys.length} orphaned files from your bucket?`,
)
) {
return;
}
if (
!(await showConfirm(
`Permanently delete all ${keys.length} orphaned files from your bucket?`,
{ confirmLabel: "Delete all", danger: true },
))
) {
return;
}
if (this.orphanDeleteAllBtn instanceof HTMLElement) {
this.orphanDeleteAllBtn.disabled = true;
+272 -6
View File
@@ -1,9 +1,20 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { ProfileAdminRenderer } from "./ProfileAdminRenderer.js";
import { getApplication } from "../../applicationContext.js";
import {
buildBlockedVideoRows,
renderBlockedVideosList,
} from "./blockedVideosSection.js";
import { showConfirm } from "../confirmDialog.js";
const noop = () => {};
// Admin pane sub-tabs, in toolbar order. "moderators" is only shown to the
// super admin (its tab button is hidden otherwise).
const ADMIN_SUBTABS = ["whitelist", "blacklist", "blockedVideos", "moderators"];
const DEFAULT_ADMIN_SUBTAB = "whitelist";
export class ProfileAdminController {
constructor(mainController) {
this.mainController = mainController;
@@ -27,6 +38,21 @@ export class ProfileAdminController {
this.addBlacklistButton = null;
this.blacklistInput = null;
this.adminBlacklistRefreshBtn = null;
// Blocked videos (per-event admin block list) sub-tab.
this.blockedVideosSection = null;
this.blockedVideosEmpty = null;
this.blockedVideosList = null;
this.addBlockedVideoButton = null;
this.blockedVideoInput = null;
this.blockedVideosRefreshBtn = null;
// Sub-tab toolbar.
this.subtabBar = null;
this.subtabButtons = {};
this.subtabSections = {};
this.activeSubtab = DEFAULT_ADMIN_SUBTAB;
this.eventBlacklistUnsub = null;
}
cacheDomReferences() {
@@ -50,6 +76,27 @@ export class ProfileAdminController {
this.addBlacklistButton = document.getElementById("adminAddBlacklistBtn") || null;
this.blacklistInput = document.getElementById("adminBlacklistInput") || null;
this.adminBlacklistRefreshBtn = document.getElementById("adminBlacklistRefreshBtn") || null;
this.blockedVideosSection = document.getElementById("adminBlockedVideosSection") || null;
this.blockedVideosEmpty = document.getElementById("adminBlockedVideosEmpty") || null;
this.blockedVideosList = document.getElementById("adminBlockedVideosList") || null;
this.addBlockedVideoButton = document.getElementById("adminAddBlockedVideoBtn") || null;
this.blockedVideoInput = document.getElementById("adminBlockedVideoInput") || null;
this.blockedVideosRefreshBtn = document.getElementById("adminBlockedVideosRefreshBtn") || null;
this.subtabBar = document.getElementById("adminSubtabBar") || null;
this.subtabButtons = {
whitelist: document.getElementById("adminSubtabWhitelist") || null,
blacklist: document.getElementById("adminSubtabBlacklist") || null,
blockedVideos: document.getElementById("adminSubtabBlockedVideos") || null,
moderators: document.getElementById("adminSubtabModerators") || null,
};
this.subtabSections = {
whitelist: this.whitelistSection,
blacklist: this.blacklistSection,
blockedVideos: this.blockedVideosSection,
moderators: this.moderatorSection,
};
}
registerEventListeners() {
@@ -155,6 +202,87 @@ export class ProfileAdminController {
}
});
}
ensureAriaLabel(this.blockedVideosRefreshBtn, "Refresh blocked videos");
for (const name of ADMIN_SUBTABS) {
const button = this.subtabButtons?.[name];
if (button instanceof HTMLElement) {
button.addEventListener("click", () => this.selectAdminSubtab(name));
}
}
if (this.addBlockedVideoButton instanceof HTMLElement) {
this.addBlockedVideoButton.addEventListener("click", () => {
void this.handleAddBlockedVideo();
});
}
if (this.blockedVideoInput instanceof HTMLElement) {
this.blockedVideoInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddBlockedVideo();
}
});
}
if (this.blockedVideosRefreshBtn instanceof HTMLElement) {
this.blockedVideosRefreshBtn.addEventListener("click", () => {
const service = this.mainController.services.accessControl;
if (!service || typeof service.refresh !== "function") {
return;
}
void service
.refresh()
.then(() => this.populateBlockedVideos())
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh blocked videos:", error);
});
});
}
// Re-render the blocked-videos list whenever the published list changes
// (e.g. an admin blocks a video from the ⋯ menu while this pane is open).
const accessControl = this.mainController.services?.accessControl;
if (accessControl && typeof accessControl.onEventBlacklistChange === "function") {
if (typeof this.eventBlacklistUnsub === "function") {
this.eventBlacklistUnsub();
}
this.eventBlacklistUnsub = accessControl.onEventBlacklistChange(() => {
this.populateBlockedVideos();
});
}
}
// Show one admin sub-tab's section and hide the rest, syncing button state.
// Falls back to the default tab when asked for a hidden one (e.g. Moderators
// for a non-super-admin).
selectAdminSubtab(name) {
let target = ADMIN_SUBTABS.includes(name) ? name : DEFAULT_ADMIN_SUBTAB;
const button = this.subtabButtons?.[target];
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
target = DEFAULT_ADMIN_SUBTAB;
}
this.activeSubtab = target;
for (const key of ADMIN_SUBTABS) {
const isActive = key === target;
const tabButton = this.subtabButtons?.[key];
const section = this.subtabSections?.[key];
if (tabButton instanceof HTMLElement) {
tabButton.setAttribute("aria-selected", isActive ? "true" : "false");
if (isActive) {
tabButton.dataset.state = "active";
} else {
delete tabButton.dataset.state;
}
}
if (section instanceof HTMLElement) {
section.classList.toggle("hidden", !isActive);
section.setAttribute("aria-hidden", (!isActive).toString());
}
}
}
populateAdminLists() {
@@ -228,6 +356,137 @@ export class ProfileAdminController {
);
}
// Best-effort lookup of a blocked video from the shared feed cache so rows can
// show a title/author; returns null when the video isn't cached (id-only row).
resolveBlockedVideo(id) {
try {
const app = getApplication();
const service = app?.nostrService || null;
if (service && typeof service.getVideoByEventId === "function") {
const video = service.getVideoByEventId(id);
if (video) return video;
}
const client = service?.nostrClient || null;
if (client?.allEvents && typeof client.allEvents.get === "function") {
return client.allEvents.get(id) || null;
}
} catch (error) {
// Non-fatal — fall back to an id-only row.
}
return null;
}
populateBlockedVideos() {
if (!(this.blockedVideosList instanceof HTMLElement)) {
return;
}
const actorNpub = this.mainController.services.getCurrentUserNpub();
const accessControl = this.mainController.services.accessControl;
if (!actorNpub || !accessControl?.canEditAdminLists?.(actorNpub)) {
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, []);
return;
}
const ids =
typeof accessControl.getEventBlacklist === "function"
? accessControl.getEventBlacklist()
: [];
const rows = buildBlockedVideoRows(ids, (id) => this.resolveBlockedVideo(id));
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, rows, {
onUnblock: (id, button) => this.handleRemoveBlockedVideo(id, button),
formatAuthor: (hex) =>
typeof hex === "string" && hex.length > 12
? `by ${hex.slice(0, 10)}…`
: "",
});
}
async handleAddBlockedVideo() {
const input = this.blockedVideoInput || null;
const rawValue = typeof input?.value === "string" ? input.value.trim() : "";
if (!rawValue) {
this.mainController.showError("Enter an event id or nevent to block.");
return;
}
const actorNpub = this.ensureAdminActor();
if (!actorNpub) {
return;
}
const accessControl = this.mainController.services.accessControl;
try {
const result = await accessControl.addToEventBlacklist(actorNpub, rawValue);
if (result?.ok) {
if (input) input.value = "";
this.mainController.showSuccess("Video added to the block list.");
this.populateBlockedVideos();
this.refreshGridsAfterEventBlacklistChange();
} else {
this.mainController.showError(
result?.error === "forbidden"
? "You do not have permission to block videos."
: result?.error === "invalid event id"
? "That doesn't look like a valid event id or nevent."
: "Failed to block the video. Please try again.",
);
}
} catch (error) {
userLogger.error("[profileModal] Failed to add blocked video:", error);
this.mainController.showError("Failed to block the video. Please try again.");
}
}
async handleRemoveBlockedVideo(id, button) {
const actorNpub = this.ensureAdminActor();
if (!actorNpub) {
return;
}
const confirmed = await showConfirm(
"Unblock this video? It will become visible across bitvid again.",
{ title: "Unblock video", confirmLabel: "Unblock", cancelLabel: "Cancel" },
);
if (!confirmed) {
return;
}
if (button instanceof HTMLElement) {
button.disabled = true;
}
const accessControl = this.mainController.services.accessControl;
try {
const result = await accessControl.removeFromEventBlacklist(actorNpub, id);
if (result?.ok) {
this.mainController.showSuccess("Video unblocked.");
this.populateBlockedVideos();
this.refreshGridsAfterEventBlacklistChange();
} else {
if (button instanceof HTMLElement) button.disabled = false;
this.mainController.showError(
result?.error === "forbidden"
? "You do not have permission to unblock videos."
: "Failed to unblock the video. Please try again.",
);
}
} catch (error) {
if (button instanceof HTMLElement) button.disabled = false;
userLogger.error("[profileModal] Failed to remove blocked video:", error);
this.mainController.showError("Failed to unblock the video. Please try again.");
}
}
refreshGridsAfterEventBlacklistChange() {
try {
const app = getApplication();
if (app && typeof app.refreshAllVideoGrids === "function") {
void app
.refreshAllVideoGrids({
reason: "admin-event-blacklist-update",
forceMainReload: true,
})
.catch(() => {});
}
} catch (error) {
// Non-fatal.
}
}
async refreshAdminPaneState() {
const adminNav = this.mainController.navButtons.admin;
const adminPane = this.mainController.panes.admin;
@@ -297,14 +556,21 @@ export class ProfileAdminController {
return;
}
if (this.moderatorSection instanceof HTMLElement) {
this.moderatorSection.classList.toggle("hidden", !isSuperAdmin);
this.moderatorSection.setAttribute(
"aria-hidden",
(!isSuperAdmin).toString(),
);
// Moderators is super-admin-only: hide its sub-tab button entirely for
// everyone else (its section visibility is then governed by selectAdminSubtab).
const moderatorsTab = this.subtabButtons?.moderators;
if (moderatorsTab instanceof HTMLElement) {
moderatorsTab.classList.toggle("hidden", !isSuperAdmin);
if (!isSuperAdmin) {
moderatorsTab.setAttribute("aria-selected", "false");
}
}
this.populateAdminLists();
this.populateBlockedVideos();
// Re-apply the current sub-tab so exactly one section is visible (and so a
// now-hidden Moderators tab falls back to the default).
this.selectAdminSubtab(this.activeSubtab);
this.mainController.showStatus(null);
this.renderer.setAdminLoading(this, false);
}
@@ -240,6 +240,32 @@ export class ProfileDirectMessageActions {
return;
}
// DMs are encrypted (NIP-17 gift wrap / NIP-04). A reloaded nsec session can
// lose its in-memory key; re-unlock it with one passphrase prompt rather than
// failing to send (TODO #57). Only user-driven outcomes stop the send; other
// cases fall through to sendDirectMessage's own error handling.
const ensureSigner =
this.mainController.services?.ensureEncryptionCapableSigner;
if (typeof ensureSigner === "function") {
let ensured = { ok: true };
try {
ensured = await ensureSigner({
pubkey: this.controller.helper.resolveActiveDmActor(),
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and send messages.",
});
} catch (error) {
ensured = { ok: false, reason: "ensure-failed" };
}
if (
ensured &&
(ensured.reason === "cancelled" || ensured.reason === "bad-passphrase")
) {
return;
}
}
const sendButton = this.controller.renderer.profileMessageSendButton;
if (sendButton instanceof HTMLElement && "disabled" in sendButton) {
sendButton.disabled = true;
@@ -489,6 +515,35 @@ export class ProfileDirectMessageActions {
return;
}
// Re-unlock a reloaded nsec signer before sending (TODO #57). Same gate as the
// profile-modal composer; only user-driven outcomes stop the send.
const ensureSigner =
this.mainController.services?.ensureEncryptionCapableSigner;
if (typeof ensureSigner === "function") {
let ensured = { ok: true };
try {
ensured = await ensureSigner({
pubkey: actor,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and send messages.",
});
} catch (error) {
ensured = { ok: false, reason: "ensure-failed" };
}
if (
ensured &&
(ensured.reason === "cancelled" || ensured.reason === "bad-passphrase")
) {
this.controller.dmComposerState = "idle";
await this.controller.renderer.renderDmAppShell(
this.controller.directMessagesCache,
{ actorPubkey: actor },
);
return;
}
}
const privacyMode =
typeof normalizedPayload.privacyMode === "string"
? normalizedPayload.privacyMode.trim().toLowerCase()
@@ -336,6 +336,45 @@ export class ProfileHashtagController {
const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {};
// Managing hashtags needs an encryption-capable signer. If a reloaded nsec
// session lost its in-memory key, re-unlock it (one passphrase prompt) instead
// of dead-ending with "connect a signer that supports encryption" (TODO #57).
const ensureSigner =
this.mainController.services?.ensureEncryptionCapableSigner;
if (typeof ensureSigner === "function") {
let ensured = { ok: true };
try {
ensured = await ensureSigner({
pubkey: normalizedPubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and manage hashtag preferences.",
});
} catch (error) {
ensured = { ok: false, reason: "ensure-failed", error };
}
if (!ensured?.ok) {
// "bad-passphrase" already showed its own toast; "cancelled" is a silent
// user choice. Only surface the generic hint for other reasons.
if (ensured?.reason !== "cancelled" && ensured?.reason !== "bad-passphrase") {
const message = this.describeHashtagPreferencesError(
Object.assign(new Error("Signer unavailable."), {
code: "hashtag-preferences-missing-signer",
}),
);
if (message) {
this.mainController.showError(message);
this.setHashtagStatus(message, "warning");
}
} else {
this.setHashtagStatus("", "muted");
}
const error = new Error("Signer unavailable for hashtag preferences.");
error.code = "hashtag-preferences-missing-signer";
throw error;
}
}
const pendingMessage =
typeof progressMessage === "string" && progressMessage.trim()
? progressMessage.trim()
+6 -4
View File
@@ -1,4 +1,5 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { showConfirm } from "../confirmDialog.js";
import { sanitizeRelayList } from "../../nostr/nip46Client.js";
const noop = () => {};
@@ -556,7 +557,7 @@ export class ProfileRelayController {
reason: null,
};
const confirmed = window.confirm("Restore the recommended relay defaults?");
const confirmed = await showConfirm("Restore the recommended relay defaults?", { confirmLabel: "Restore" });
context.confirmed = confirmed;
if (!confirmed) {
context.reason = "cancelled";
@@ -604,9 +605,10 @@ export class ProfileRelayController {
return;
}
const confirmed = window.confirm(
`Remove ${url} from your relay list?`,
);
const confirmed = await showConfirm(`Remove ${url} from your relay list?`, {
confirmLabel: "Remove",
danger: true,
});
if (!confirmed) {
return;
}
+30 -64
View File
@@ -10,6 +10,14 @@ import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
import {
syncStatusClass,
runSyncToggle,
runSyncRestore,
confirmSyncOverwrite,
} from "./syncSection.js";
const STORAGE_SYNC_LABEL = "storage settings";
import {
fillStorageForm,
clearCredentialFields,
@@ -240,16 +248,6 @@ export class ProfileStorageController {
this.updateStorageFormVisibility();
}
confirmSyncOverwrite() {
if (typeof window === "undefined" || typeof window.confirm !== "function") {
return true;
}
return window.confirm(
"A newer copy of your storage settings is on your account (changed on " +
"another device). Overwrite it with this one?"
);
}
renderSyncSection(pubkey) {
if (!(this.storageSyncSection instanceof HTMLElement)) {
return;
@@ -272,13 +270,7 @@ export class ProfileStorageController {
return;
}
this.storageSyncStatus.textContent = message || "";
const toneClass =
tone === "success"
? "text-status-success"
: tone === "error"
? "text-status-danger"
: "text-muted";
this.storageSyncStatus.className = `text-xs ${toneClass}`;
this.storageSyncStatus.className = syncStatusClass(tone);
}
async handleToggleSync() {
@@ -293,41 +285,20 @@ export class ProfileStorageController {
? this.storageSyncToggle.checked
: false;
try {
if (enabled) {
this.setSyncStatus("Encrypting and publishing…");
const result = await storageSyncService.enable(pubkey, {
confirmOverwrite: () => this.confirmSyncOverwrite(),
});
if (result?.ok) {
this.setSyncStatus(
`Synced to ${result.accepted}/${result.total} relays.`,
"success"
);
this.mainController.showSuccess("Storage settings synced (encrypted).");
} else if (result?.conflict) {
// User declined to overwrite a newer copy from another device.
this.setSyncStatus(
"Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.",
);
} else {
// Roll the toggle back so it reflects reality.
await runSyncToggle({
service: storageSyncService,
pubkey,
enabled,
itemLabel: STORAGE_SYNC_LABEL,
emptyHint: "Nothing to sync yet — save a connection first.",
setStatus: (msg, tone) => this.setSyncStatus(msg, tone),
showSuccess: (msg) => this.mainController.showSuccess(msg),
setToggle: (value) => {
if (this.storageSyncToggle instanceof HTMLInputElement) {
this.storageSyncToggle.checked = false;
this.storageSyncToggle.checked = value;
}
await storageSyncService.disable(pubkey).catch(() => {});
this.setSyncStatus(
result?.error === "nothing-to-sync"
? "Nothing to sync yet — save a connection first."
: "Could not publish the encrypted copy. Try again.",
"error"
);
}
} else {
this.setSyncStatus("Removing the synced copy…");
await storageSyncService.disable(pubkey);
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
this.mainController.showSuccess("Storage sync turned off.");
}
},
});
} catch (error) {
devLogger.error("[ProfileModal] Storage sync toggle failed:", error);
this.setSyncStatus("Sync failed. Please try again.", "error");
@@ -342,20 +313,15 @@ export class ProfileStorageController {
return;
}
try {
this.setSyncStatus("Fetching and decrypting…");
const result = await storageSyncService.pull(pubkey);
if (result?.found && result.imported) {
this.setSyncStatus("Restored from your Nostr account.", "success");
this.mainController.showSuccess("Storage settings restored.");
await runSyncRestore({
service: storageSyncService,
pubkey,
itemLabel: STORAGE_SYNC_LABEL,
setStatus: (msg, tone) => this.setSyncStatus(msg, tone),
showSuccess: (msg) => this.mainController.showSuccess(msg),
// Re-render the pane so the restored connection shows.
await this.populateStoragePane();
} else if (result?.found && !result.imported) {
this.setSyncStatus("Found a copy but could not import it.", "error");
} else if (result?.cleared) {
this.setSyncStatus("No synced settings found (it was cleared).");
} else {
this.setSyncStatus("No synced settings found on your account.");
}
onImported: () => this.populateStoragePane(),
});
} catch (error) {
devLogger.error("[ProfileModal] Storage sync restore failed:", error);
this.setSyncStatus("Restore failed. Please try again.", "error");
@@ -862,7 +828,7 @@ export class ProfileStorageController {
if (storageSyncService.isEnabled(pubkey)) {
try {
const syncResult = await storageSyncService.push(pubkey, {
confirmOverwrite: () => this.confirmSyncOverwrite(),
confirmOverwrite: () => confirmSyncOverwrite(STORAGE_SYNC_LABEL),
});
if (syncResult?.ok) {
this.setSyncStatus("Synced copy updated.", "success");
+40 -79
View File
@@ -1,7 +1,16 @@
import { devLogger } from "../../utils/logger.js";
import { showConfirm } from "../confirmDialog.js";
import { NWC_URI_SCHEME } from "../profileModalContract.js";
import { createWalletSyncService } from "../../services/walletSyncService.js";
import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js";
import {
syncStatusClass,
runSyncToggle,
runSyncRestore,
confirmSyncOverwrite,
} from "./syncSection.js";
const WALLET_SYNC_LABEL = "wallet connection";
const noop = () => {};
const SECRET_PLACEHOLDER = "*****";
@@ -103,16 +112,6 @@ export class ProfileWalletController {
return this._walletSyncService;
}
confirmSyncOverwrite() {
if (typeof window === "undefined" || typeof window.confirm !== "function") {
return true;
}
return window.confirm(
"A newer copy of your wallet connection is on your account (changed on " +
"another device). Overwrite it with this one?"
);
}
renderSyncSection(pubkey) {
if (!(this.walletSyncSection instanceof HTMLElement)) {
return;
@@ -134,13 +133,7 @@ export class ProfileWalletController {
return;
}
this.walletSyncStatus.textContent = message || "";
const toneClass =
tone === "success"
? "text-status-success"
: tone === "error"
? "text-status-danger"
: "text-muted";
this.walletSyncStatus.className = `text-xs ${toneClass}`;
this.walletSyncStatus.className = syncStatusClass(tone);
}
async handleToggleSync() {
@@ -156,56 +149,29 @@ export class ProfileWalletController {
? this.walletSyncToggle.checked
: false;
try {
if (enabled) {
// Spending capability — require explicit confirmation before publishing.
const confirmed =
typeof window !== "undefined" && typeof window.confirm === "function"
? window.confirm(
"This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" +
"A wallet connect URI can SPEND from your wallet. Anyone who controls your " +
"Nostr key could decrypt this and spend from this wallet.\n\nContinue?"
)
: true;
if (!confirmed) {
await runSyncToggle({
service: sync,
pubkey,
enabled,
itemLabel: WALLET_SYNC_LABEL,
emptyHint: "Connect a wallet first, then enable sync.",
setStatus: (msg, tone) => this.setSyncStatus(msg, tone),
showSuccess: (msg) => this.mainController.showSuccess(msg),
setToggle: (value) => {
if (this.walletSyncToggle instanceof HTMLInputElement) {
this.walletSyncToggle.checked = false;
this.walletSyncToggle.checked = value;
}
return;
}
this.setSyncStatus("Encrypting and publishing…");
const result = await sync.enable(pubkey, {
confirmOverwrite: () => this.confirmSyncOverwrite(),
});
if (result?.ok) {
this.setSyncStatus(
`Synced to ${result.accepted}/${result.total} relays.`,
"success"
);
this.mainController.showSuccess("Wallet connection synced (encrypted).");
} else if (result?.conflict) {
// User declined to overwrite a newer copy from another device. Keep
// sync on (flag set) and the newer remote intact.
this.setSyncStatus(
"Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.",
);
} else {
if (this.walletSyncToggle instanceof HTMLInputElement) {
this.walletSyncToggle.checked = false;
}
await sync.disable(pubkey).catch(() => {});
this.setSyncStatus(
result?.error === "nothing-to-sync"
? "Connect a wallet first, then enable sync."
: "Could not publish the encrypted copy. Try again.",
"error"
);
}
} else {
this.setSyncStatus("Removing the synced copy…");
await sync.disable(pubkey);
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
this.mainController.showSuccess("Wallet sync turned off.");
}
},
// Spending capability — a wallet-connect URI can SPEND. Keep the explicit
// one-time warning before publishing (even encrypted) to public relays.
preEnableConfirm: () =>
showConfirm(
"This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" +
"A wallet connect URI can SPEND from your wallet. Anyone who controls your " +
"Nostr key could decrypt this and spend from this wallet.\n\nContinue?",
{ confirmLabel: "Publish", danger: true },
),
});
} catch (error) {
devLogger.error("[ProfileModal] Wallet sync toggle failed:", error);
this.setSyncStatus("Sync failed. Please try again.", "error");
@@ -221,19 +187,14 @@ export class ProfileWalletController {
}
const sync = this.getWalletSyncService();
try {
this.setSyncStatus("Fetching and decrypting…");
const result = await sync.pull(pubkey);
if (result?.found && result.imported) {
this.setSyncStatus("Restored from your Nostr account.", "success");
this.mainController.showSuccess("Wallet connection restored.");
this.refreshWalletPaneState();
} else if (result?.found && !result.imported) {
this.setSyncStatus("Found a copy but could not import it.", "error");
} else if (result?.cleared) {
this.setSyncStatus("No synced wallet found (it was cleared).");
} else {
this.setSyncStatus("No synced wallet found on your account.");
}
await runSyncRestore({
service: sync,
pubkey,
itemLabel: WALLET_SYNC_LABEL,
setStatus: (msg, tone) => this.setSyncStatus(msg, tone),
showSuccess: (msg) => this.mainController.showSuccess(msg),
onImported: () => this.refreshWalletPaneState(),
});
} catch (error) {
devLogger.error("[ProfileModal] Wallet sync restore failed:", error);
this.setSyncStatus("Restore failed. Please try again.", "error");
@@ -644,7 +605,7 @@ export class ProfileWalletController {
if (sync.isEnabled(normalizedActive)) {
try {
const syncResult = await sync.push(normalizedActive, {
confirmOverwrite: () => this.confirmSyncOverwrite(),
confirmOverwrite: () => confirmSyncOverwrite(WALLET_SYNC_LABEL),
});
if (syncResult?.conflict) {
this.setSyncStatus(
+136
View File
@@ -0,0 +1,136 @@
// Helpers for the admin pane's "Blocked videos" sub-tab (#25 follow-up): render
// the per-event admin block list with an unblock control, and add-by-id. Kept
// separate from ProfileAdminController so that controller stays under its size
// cap and the row-building logic is unit-testable without a DOM.
// Shorten a 64-char hex event id (or any long token) for compact display.
export function shortenEventId(id) {
const value = typeof id === "string" ? id.trim() : "";
if (value.length <= 18) {
return value;
}
return `${value.slice(0, 10)}…${value.slice(-6)}`;
}
// Pure: turn the raw blocked-id list into display rows, enriching with a video's
// title/author when `resolveVideo(id)` can find it in the local cache (falls
// back to the shortened id). De-dupes and drops blanks so the UI can't render a
// ghost row for a malformed entry.
export function buildBlockedVideoRows(ids, resolveVideo) {
const list = Array.isArray(ids) ? ids : [];
const seen = new Set();
const rows = [];
for (const raw of list) {
const id = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
let title = "";
let author = "";
if (typeof resolveVideo === "function") {
try {
const video = resolveVideo(id);
if (video && typeof video === "object") {
title = typeof video.title === "string" ? video.title.trim() : "";
author = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
}
} catch (error) {
// Best-effort enrichment; fall back to the id.
}
}
rows.push({
id,
title,
author,
// Primary line: the video title when known, else the shortened id.
label: title || shortenEventId(id),
// Secondary line: the shortened id, only when the title is the primary.
sublabel: title ? shortenEventId(id) : "",
});
}
return rows;
}
// Thin DOM render for the blocked-videos <ul>. Mirrors the admin list card
// styling. `onUnblock(id, button)` handles confirm + removal in the controller.
export function renderBlockedVideosList(
listEl,
emptyEl,
rows,
{ onUnblock, formatAuthor } = {},
) {
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
return;
}
listEl.textContent = "";
const setHidden = (element, hidden) => {
if (!(element instanceof HTMLElement)) {
return;
}
element.classList.toggle("hidden", hidden);
if (hidden) {
element.setAttribute("hidden", "");
} else {
element.removeAttribute("hidden");
}
};
if (!Array.isArray(rows) || rows.length === 0) {
setHidden(emptyEl, false);
setHidden(listEl, true);
return;
}
setHidden(emptyEl, true);
setHidden(listEl, false);
for (const row of rows) {
const item = document.createElement("li");
item.className =
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
item.dataset.eventId = row.id;
const info = document.createElement("div");
info.className = "min-w-0 space-y-1";
const label = document.createElement("p");
label.className = "truncate font-medium text-text";
label.textContent = row.label;
label.title = row.id;
info.appendChild(label);
const authorText =
row.author && typeof formatAuthor === "function"
? formatAuthor(row.author)
: "";
const secondaryText = [row.sublabel, authorText].filter(Boolean).join(" · ");
if (secondaryText) {
const sub = document.createElement("p");
sub.className = "truncate text-xs text-muted";
sub.textContent = secondaryText;
info.appendChild(sub);
}
item.appendChild(info);
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring";
button.dataset.variant = "danger";
button.textContent = "Unblock";
button.setAttribute("aria-label", `Unblock video ${row.label}`);
button.addEventListener("click", () => {
if (typeof onUnblock === "function") {
onUnblock(row.id, button);
}
});
item.appendChild(button);
listEl.appendChild(item);
}
}
+118
View File
@@ -0,0 +1,118 @@
// Shared UI for a syncable profile item (NWC wallet, storage credentials, …).
// Previously ProfileWalletController and ProfileStorageController each had their
// own copy of the sync-toggle / restore / overwrite-confirm logic, which produced
// inconsistent notifications and duplicate confirm dialogs. This module is the one
// implementation both use, so every item gets identical toasts, one overwrite
// confirm, and the same conflict-only behavior. Item-specific bits (the DOM status
// element, the "empty" hint, an optional pre-enable warning, and the re-render
// after restore) are passed in.
import { showConfirm } from "../confirmDialog.js";
function capitalize(label) {
const value = typeof label === "string" ? label.trim() : "";
return value ? value[0].toUpperCase() + value.slice(1) : value;
}
// The ONE overwrite-confirm dialog, shown only on a real conflict (remote is
// newer — changed on another device). itemLabel = "wallet connection" | "storage
// settings".
export function confirmSyncOverwrite(itemLabel) {
return showConfirm(
`A newer copy of your ${itemLabel} is on your account (changed on another ` +
`device). Overwrite it with this one?`,
{ confirmLabel: "Overwrite", danger: true },
);
}
// tone → the status line's className (consistent across items).
export function syncStatusClass(tone) {
const toneClass =
tone === "success"
? "text-status-success"
: tone === "error"
? "text-status-danger"
: "text-muted";
return `text-xs ${toneClass}`;
}
// Shared enable/disable handler. `setToggle` reflects reality on rollback;
// `preEnableConfirm` is an optional async gate (e.g. the wallet spending-secret
// warning); `emptyHint` is the "nothing-to-sync" message.
export async function runSyncToggle({
service,
pubkey,
enabled,
itemLabel,
setStatus,
showSuccess,
setToggle,
preEnableConfirm,
emptyHint = "Nothing to sync yet — save something first.",
}) {
if (enabled) {
if (typeof preEnableConfirm === "function") {
const confirmed = await preEnableConfirm();
if (!confirmed) {
setToggle?.(false);
return { ok: false, cancelled: true };
}
}
setStatus("Encrypting and publishing…");
const result = await service.enable(pubkey, {
confirmOverwrite: () => confirmSyncOverwrite(itemLabel),
});
if (result?.ok) {
setStatus(`Synced to ${result.accepted}/${result.total} relays.`, "success");
showSuccess?.(`${capitalize(itemLabel)} synced (encrypted).`);
} else if (result?.conflict) {
// Declined to overwrite a newer remote copy — keep sync on, remote intact.
setStatus(
"Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.",
);
} else {
setToggle?.(false);
await service.disable(pubkey).catch(() => {});
setStatus(
result?.error === "nothing-to-sync"
? emptyHint
: "Could not publish the encrypted copy. Try again.",
"error",
);
}
return result;
}
setStatus("Removing the synced copy…");
await service.disable(pubkey);
setStatus("Sync turned off; the synced copy was cleared.");
showSuccess?.(`${capitalize(itemLabel)} sync turned off.`);
return { ok: true, disabled: true };
}
// Shared restore (pull) handler. `onImported` re-renders the item's pane.
export async function runSyncRestore({
service,
pubkey,
itemLabel,
setStatus,
showSuccess,
onImported,
}) {
setStatus("Fetching and decrypting…");
const result = await service.pull(pubkey);
if (result?.found && result.imported) {
setStatus("Restored from your Nostr account.", "success");
showSuccess?.(`${capitalize(itemLabel)} restored.`);
if (typeof onImported === "function") {
await onImported();
}
} else if (result?.found && !result.imported) {
setStatus("Found a copy but could not import it.", "error");
} else if (result?.cleared) {
setStatus("No synced settings found (it was cleared).");
} else {
setStatus("No synced settings found on your account.");
}
return result;
}
+123 -32
View File
@@ -6,6 +6,7 @@ import {
MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP,
ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING,
} from "../config.js";
import { showConfirm } from "./confirmDialog.js";
import { normalizeDesignSystemContext } from "../designSystem.js";
import { RUNTIME_FLAGS } from "../constants.js";
import {
@@ -359,6 +360,8 @@ export class ProfileModalController {
this.closeButton = null;
this.logoutButton = null;
this.mobileLogoutButton = null;
this.lockNowButton = null;
this.mobileLockNowButton = null;
this.channelLink = null;
this.addAccountButton = null;
this.navButtons = {
@@ -656,6 +659,9 @@ export class ProfileModalController {
this.logoutButton = document.getElementById("profileLogoutBtn") || null;
this.mobileLogoutButton =
document.getElementById("profileMobileLogoutBtn") || null;
this.lockNowButton = document.getElementById("profileLockNowBtn") || null;
this.mobileLockNowButton =
document.getElementById("profileMobileLockNowBtn") || null;
this.channelLink = document.getElementById("profileChannelLink") || null;
this.addAccountButton =
document.getElementById("profileAddAccountBtn") || null;
@@ -1257,6 +1263,58 @@ export class ProfileModalController {
void this.refreshDmRelayPreferences({ force: true });
}
// Run one identity-switch reset step in isolation. A failure in a single step
// must not skip the ones after it — notably the DM reset that clears the
// previous account's conversation on a switch.
runIdentityResetStep(label, fn) {
try {
fn();
} catch (error) {
userLogger.warn(
`[profileModal] Identity reset step "${label}" failed:`,
error,
);
}
}
// Refresh the profile modal for the ACTIVE identity while it is open (e.g. right
// after switching accounts). Re-runs the current pane's own lazy load+populate —
// the SAME path as opening the pane fresh — so DMs reload, subscriptions and
// hashtags refetch for the new account, blocked/relays/storage repopulate, etc.
// Non-active panes stay lazy and repopulate when the user selects them.
refreshOpenPanesForActiveIdentity() {
if (!this.isProfileModalOpen()) {
return;
}
try {
this.selectPane(this.getActivePane());
} catch (error) {
userLogger.warn(
"[profileModal] Failed to refresh the active pane for the current identity:",
error,
);
}
}
// Show the "Lock this device" control only when there is a cached "keep
// unlocked" key to forget (an nsec account currently kept unlocked). TODO #51.
updateLockNowVisibility() {
const check = this.services?.isSessionKeptUnlocked;
let unlocked = false;
if (typeof check === "function") {
try {
unlocked = Boolean(check(this.getActivePubkey()));
} catch (error) {
unlocked = false;
}
}
for (const button of [this.lockNowButton, this.mobileLockNowButton]) {
if (button instanceof HTMLElement) {
button.classList.toggle("hidden", !unlocked);
}
}
}
setMessagesUnreadIndicator(visible) {
if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) {
return;
@@ -1638,6 +1696,25 @@ export class ProfileModalController {
});
}
const handleLockNow = () => {
const lock = this.services?.lockKeptUnlockedSession;
if (typeof lock !== "function") {
return;
}
try {
lock(this.getActivePubkey());
} catch (error) {
devLogger.warn("[profileModal] Lock this device failed:", error);
}
this.updateLockNowVisibility();
};
if (this.lockNowButton instanceof HTMLElement) {
this.lockNowButton.addEventListener("click", handleLockNow);
}
if (this.mobileLockNowButton instanceof HTMLElement) {
this.mobileLockNowButton.addEventListener("click", handleLockNow);
}
if (this.channelLink instanceof HTMLElement) {
this.channelLink.addEventListener("click", (event) => {
event.preventDefault();
@@ -1926,14 +2003,17 @@ export class ProfileModalController {
}
if (this.profileSubscriptionsBackupBtn) {
this.profileSubscriptionsBackupBtn.addEventListener("click", () => {
this.profileSubscriptionsBackupBtn.addEventListener("click", async () => {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (pubkey) {
if (confirm("Create a backup of your current subscription list?")) {
if (
pubkey &&
(await showConfirm("Create a backup of your current subscription list?", {
confirmLabel: "Create backup",
}))
) {
this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => {
this.showSuccess("Backup created.");
});
}
} else {
this.showError("Please log in to backup subscriptions.");
}
@@ -2328,6 +2408,7 @@ export class ProfileModalController {
* Triggers a batch fetch for any missing profile metadata.
*/
renderSavedProfiles() {
this.updateLockNowVisibility();
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
const entriesNeedingFetch = new Set();
const savedProfiles = this.getSavedProfiles();
@@ -2930,7 +3011,7 @@ export class ProfileModalController {
? confirmValue.trim()
: "this entry";
const prompt = confirmMessage.replace("{npub}", replacement);
if (!window.confirm(prompt)) {
if (!(await showConfirm(prompt, { danger: true }))) {
return;
}
}
@@ -3200,6 +3281,7 @@ export class ProfileModalController {
}
selectPane(name = "account", options = {}) {
this.updateLockNowVisibility();
const { keepMenuView = false } =
options && typeof options === "object" ? options : {};
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
@@ -5219,46 +5301,57 @@ export class ProfileModalController {
this.setActivePubkey(activePubkey);
}
// Reset identity-scoped UI state up front, each step isolated so one throwing
// can't skip the others. Regression this guards: switching from NIP-07 to a
// saved nsec (modal open) left the PREVIOUS account's DMs, subscriptions and
// hashtags on screen because a sibling call threw before the DM reset ran.
// DM identity setup also feeds the background unread indicator + DM
// subscription, so it stays eager.
this.runIdentityResetStep("dm-identity", () =>
this.handleActiveDmIdentityChanged(activePubkey),
);
this.runIdentityResetStep("saved-profiles", () => this.renderSavedProfiles());
const walletPromise = this.hydrateActiveWalletSettings(activePubkey);
const adminPromise = this.refreshAdminPaneState().catch((error) => {
userLogger.warn("Failed to refresh admin pane after login:", error);
});
this.renderSavedProfiles();
// Warm blocks + subscriptions, but go through ensureLoaded() (not the raw
// loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's
// login-time list loads instead of issuing a second, ungated fetch. The
// direct calls here each kicked off an independent relay round-trip,
// doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0).
if (activePubkey) {
if (this.services.userBlocks?.ensureLoaded) {
this.services.userBlocks.ensureLoaded(activePubkey).catch(noop);
} else if (this.services.userBlocks?.loadBlocks) {
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
}
if (this.subscriptionsService?.ensureLoaded) {
this.subscriptionsService
.ensureLoaded(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
} else if (this.subscriptionsService?.loadSubscriptions) {
this.subscriptionsService
.loadSubscriptions(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
}
this.runIdentityResetStep("warm-lists", () => {
if (this.services.userBlocks?.ensureLoaded) {
this.services.userBlocks.ensureLoaded(activePubkey).catch(noop);
} else if (this.services.userBlocks?.loadBlocks) {
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
}
if (this.subscriptionsService?.ensureLoaded) {
this.subscriptionsService
.ensureLoaded(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
} else if (this.subscriptionsService?.loadSubscriptions) {
this.subscriptionsService
.loadSubscriptions(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
}
});
}
// DM identity is cheap state setup needed for the background unread
// indicator + DM subscription, so it stays eager.
this.handleActiveDmIdentityChanged(activePubkey);
// Profile-PANEL rendering is deferred: each pane lazily populates when it's
// shown (selectPane), so eagerly rendering all of them at login paints into
// CLOSED panels — friends-list avatars, subscriptions, blocks, storage,
// relays — and jams the main thread right after login (the ~10-15s "profile
// panel won't open / site frozen" report). Only do the eager pass when the
// modal is already open (e.g. switching accounts with it visible);
// otherwise open()/selectPane handles it on demand.
// panel won't open / site frozen" report). Only refresh when the modal is
// already open (e.g. switching accounts with it visible); otherwise
// open()/selectPane handles it on demand. On a switch we re-run the ACTIVE
// pane's own load+populate (refreshOpenPanesForActiveIdentity) so the pane the
// user is viewing reloads for the new identity, then again once profile/wallet
// data settles (list panes also backfill via the auth coordinator once the new
// identity's lists finish loading).
const settleLoginData = Promise.all([
walletPromise,
adminPromise,
@@ -5266,12 +5359,10 @@ export class ProfileModalController {
]);
if (this.isProfileModalOpen()) {
this.populateOpenProfilePanels();
this.refreshOpenPanesForActiveIdentity();
settleLoginData
.then(() => {
if (this.isProfileModalOpen()) {
this.populateOpenProfilePanels();
}
this.refreshOpenPanesForActiveIdentity();
})
.catch((error) => {
userLogger.warn(
+154
View File
@@ -0,0 +1,154 @@
// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of
// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g.
// when switching to a saved nsec account. Resolves the entered string, or null if the
// user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above
// other modals (and is typable thanks to the stacked-modal focus fix).
// When `collectRemember` is set, an opt-in "keep unlocked" checkbox is shown and
// the prompt resolves `{ passphrase, remember }` instead of a bare string (cancel
// still resolves null). Callers that don't pass `collectRemember` keep the legacy
// string resolution.
export function showPasswordPrompt(message, options = {}) {
const {
title = "",
confirmLabel = "Unlock",
cancelLabel = "Cancel",
placeholder = "PIN / passphrase",
collectRemember = false,
rememberLabel = "Keep me unlocked on this device",
rememberHint =
"Stored unencrypted on this device until you clear site data. Anyone with access to this browser can use it. Leave off to re-enter after closing the tab.",
rememberDefault = false,
} = options || {};
if (typeof document === "undefined" || !document.body) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5 space-y-3";
if (message) {
const text = document.createElement("p");
text.className = "text-sm text-muted-strong whitespace-pre-line";
text.textContent = String(message);
body.appendChild(text);
}
const input = document.createElement("input");
input.type = "password";
input.className = "input w-full";
input.placeholder = placeholder;
input.autocomplete = "off";
input.setAttribute("aria-label", placeholder);
body.appendChild(input);
let rememberCheckbox = null;
if (collectRemember) {
const rememberWrap = document.createElement("label");
rememberWrap.className = "flex items-start gap-2 cursor-pointer";
rememberCheckbox = document.createElement("input");
rememberCheckbox.type = "checkbox";
rememberCheckbox.className = "mt-1";
rememberCheckbox.checked = Boolean(rememberDefault);
const rememberText = document.createElement("span");
rememberText.className = "text-sm text-muted-strong";
const rememberTitle = document.createElement("span");
rememberTitle.className = "block text-text";
rememberTitle.textContent = rememberLabel;
rememberText.appendChild(rememberTitle);
if (rememberHint) {
const hint = document.createElement("span");
hint.className = "block text-xs text-status-danger";
hint.textContent = rememberHint;
rememberText.appendChild(hint);
}
rememberWrap.appendChild(rememberCheckbox);
rememberWrap.appendChild(rememberText);
body.appendChild(rememberWrap);
}
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-ghost focus-ring";
cancelBtn.textContent = cancelLabel;
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "btn focus-ring";
confirmBtn.textContent = confirmLabel;
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
const submit = () =>
cleanup(
collectRemember
? {
passphrase: input.value,
remember: Boolean(rememberCheckbox && rememberCheckbox.checked),
}
: input.value,
);
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(null);
}
}
cancelBtn.addEventListener("click", () => cleanup(null));
backdrop.addEventListener("click", () => cleanup(null));
confirmBtn.addEventListener("click", submit);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
});
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
input.focus();
});
}
export default showPasswordPrompt;
+29
View File
@@ -19,6 +19,10 @@ export default class ReactionController {
isUserLoggedIn: callbacks.isUserLoggedIn || (() => false),
normalizeHexPubkey: callbacks.normalizeHexPubkey || ((val) => val),
getPubkey: callbacks.getPubkey || (() => null),
// Ensure a sign-capable signer before publishing a reaction (kind 7). A
// reloaded nsec session can lose its in-memory key; this re-unlocks it with
// one passphrase prompt instead of the reaction silently failing (TODO #54).
ensureSigner: callbacks.ensureSigner || (async () => ({ ok: true })),
};
this.reactionState = {
@@ -336,6 +340,31 @@ export default class ReactionController {
return;
}
// A reloaded nsec session keeps the pubkey/UI but drops the in-memory signer,
// so publishing the kind-7 reaction would fail with a generic "Failed to send
// reaction" and the button would appear dead. Re-unlock the signer first (one
// passphrase prompt). Gate BEFORE the optimistic update so nothing flashes.
let ensured = { ok: true };
try {
ensured = await this.callbacks.ensureSigner({
need: "sign",
pubkey: this.callbacks.getPubkey(),
promptMessage:
"Re-enter your PIN / passphrase to unlock your key and react to videos.",
});
} catch (error) {
devLogger.warn("[reaction] Signer gate failed:", error);
ensured = { ok: false, reason: "ensure-failed" };
}
if (ensured && ensured.ok === false) {
videoModal.setUserReaction(previousReaction);
// cancelled = user backed out; bad-passphrase already showed its own toast.
if (ensured.reason !== "cancelled" && ensured.reason !== "bad-passphrase") {
this.ui.showError("Connect a signer to react to videos.");
}
return;
}
let rollbackSnapshot = null;
try {
rollbackSnapshot = this.applyOptimisticUpdate(
+9 -7
View File
@@ -3,6 +3,7 @@ import { profileCache } from "../state/profileCache.js";
import { formatTimeAgo } from "../utils/formatters.js";
import { userLogger } from "../utils/logger.js";
import { getApplication } from "../applicationContext.js";
import { notifyError, notifySuccess, showConfirm } from "./appNotify.js";
export class SubscriptionHistoryController {
constructor(options = {}) {
@@ -302,9 +303,10 @@ export class SubscriptionHistoryController {
async handleRestore() {
if (!this.selectedEvent) return;
if (
!confirm(
"Are you sure you want to restore this list? It will overwrite your current subscriptions."
)
!(await showConfirm(
"Are you sure you want to restore this list? It will overwrite your current subscriptions.",
{ confirmLabel: "Restore", danger: true },
))
)
return;
@@ -313,11 +315,11 @@ export class SubscriptionHistoryController {
try {
await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey);
// alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now
notifySuccess("Subscriptions restored.");
this.hide();
} catch (error) {
userLogger.error("Restore failed:", error);
alert("Failed to restore: " + error.message);
notifyError("Failed to restore: " + error.message);
} finally {
this.restoreBtn.disabled = false;
this.restoreBtn.textContent = "Restore as Active";
@@ -338,7 +340,7 @@ export class SubscriptionHistoryController {
this.fetchHistory();
} catch (error) {
userLogger.error("Rebroadcast failed:", error);
alert("Failed to rebroadcast: " + error.message);
notifyError("Failed to rebroadcast: " + error.message);
} finally {
this.rebroadcastBtn.disabled = false;
this.rebroadcastBtn.textContent = "Rebroadcast";
@@ -364,7 +366,7 @@ export class SubscriptionHistoryController {
}
} catch (error) {
userLogger.error("Backup failed:", error);
alert("Backup failed: " + error.message);
notifyError("Backup failed: " + error.message);
}
}
}
+12 -18
View File
@@ -24,7 +24,7 @@ import {
import { STANDARD_TIMEOUT_MS, MAX_BLOCKLIST_ENTRIES } from "./constants.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
assertAnyRelayAcceptedOrUnconfirmed,
} from "./nostrPublish.js";
import { profileCache } from "./state/profileCache.js";
import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js";
@@ -2383,23 +2383,17 @@ class UserBlockListManager {
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "mute list",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
userLogger.error(
`[UserBlockList] Mute list rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
// An all-timeout (unconfirmed) publish of this idempotent, replaceable mute
// list is treated as a soft success — the event was sent and almost always
// persists (it shows up after a refresh), so don't surface a spurious error /
// revert the optimistic state. Explicit rejections still throw.
const publishSummary = assertAnyRelayAcceptedOrUnconfirmed(publishResults, {
context: "mute list",
});
if (publishSummary.unconfirmed) {
userLogger.warn(
"[UserBlockList] Mute list not acknowledged by any relay within the timeout; treating as optimistic success (reconciles on next load).",
);
}
if (publishSummary.failed.length) {
+69
View File
@@ -0,0 +1,69 @@
// Rank a user's previously-used hashtags by how many of their videos use each one,
// for the upload modal's one-tap suggestion chips (TODO #45). Pure + side-effect
// free so the ranking is unit-testable without the app shell.
//
// Input: an array of the user's own video objects. Each may carry hashtags as
// `nip71.hashtags` / `nip71.t` (parsed NIP-71 metadata) or as raw event `t` tags.
// Output: [{ tag, count }] sorted by count desc then alphabetically, normalized +
// deduped (a tag is counted once per video, so `count` = number of videos using it).
function normalizeTag(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/^#+/, "").toLowerCase();
}
export function extractVideoHashtags(video) {
if (!video || typeof video !== "object") {
return [];
}
const nip71 = video.nip71;
if (nip71 && typeof nip71 === "object") {
if (Array.isArray(nip71.hashtags)) {
return nip71.hashtags;
}
if (Array.isArray(nip71.t)) {
return nip71.t;
}
}
// Fallback: raw event `t` tags, minus bitvid's fixed "video" marker tag.
if (Array.isArray(video.tags)) {
return video.tags
.filter(
(tag) =>
Array.isArray(tag) &&
tag[0] === "t" &&
typeof tag[1] === "string" &&
tag[1] &&
tag[1] !== "video",
)
.map((tag) => tag[1]);
}
return [];
}
export function rankHashtagsByFrequency(videos, { limit } = {}) {
const counts = new Map();
const list = Array.isArray(videos) ? videos : [];
for (const video of list) {
const seenInThisVideo = new Set();
for (const raw of extractVideoHashtags(video)) {
const tag = normalizeTag(raw);
if (!tag || seenInThisVideo.has(tag)) {
continue;
}
seenInThisVideo.add(tag);
counts.set(tag, (counts.get(tag) || 0) + 1);
}
}
const ranked = Array.from(counts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
return Number.isFinite(limit) && limit > 0 ? ranked.slice(0, limit) : ranked;
}
export default rankHashtagsByFrequency;
+10
View File
@@ -12,6 +12,16 @@ export function normalizeHexString(value) {
export const normalizeHexId = normalizeHexString;
export const normalizeHexPubkey = normalizeHexString;
// Lowercase hex encoding of a byte array (e.g. a generated secret key).
export function bytesToHex(bytes) {
if (!bytes) {
return "";
}
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export function normalizeHexHash(value) {
if (value === undefined || value === null) {
return "";
+2 -2
View File
@@ -19,8 +19,8 @@ import { join, relative } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const SOURCE_DIR = join(ROOT, "js");
const THRESHOLD = 1000; // lines — new files must stay under this
const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes
const THRESHOLD = 1200; // lines — new files must stay under this (soft ceiling)
const GROWTH_MARGIN = 200; // lines — allowlisted files may grow by this much for fixes
// Grandfathered files: path relative to repo root → line count when recorded.
// These files are known to be oversized and are decomposition targets.
+5
View File
@@ -36,6 +36,11 @@ const IGNORED_GLOBS = [
'bitvid-working-webtorrent/**',
'scripts/agent/**',
'docs/capacitor-native-app-plan.md',
// Planning docs cite TODO/issue refs (e.g. "16c", "16b") that read as 3-digit hex.
'docs/shorts-plan.md',
'docs/live-ingest-plan.md',
'docs/live-publish-plan.md',
'docs/bitvid-bridge-plan.md',
'perf/**',
'test_logs/**',
'context/**',
+28
View File
@@ -667,4 +667,32 @@ assert.ok(
"should warn when blacklist updates fail on a relay",
);
// Regression: admin list edits must sign with the ACTIVE signer (nsec / NIP-46), not
// window.nostr — persistNostrState previously hardcoded the NIP-07 extension, so nsec
// and NIP-46 logins couldn't edit the whitelist.
let nsecSignerCalls = 0;
globalThis.window.nostr.signEvent = async () => {
throw new Error("window.nostr must NOT be used to sign admin lists");
};
setActiveSigner({
pubkey: superHex,
type: "nsec",
signEvent: async (event) => {
nsecSignerCalls += 1;
return { ...event, id: "nsec-signed" };
},
});
nostrClient.writeRelays = [relays[1]];
setPublishBehaviors([{ url: relays[1], success: true }]);
await assert.doesNotReject(
() =>
persistAdminState(ADMIN_SUPER_NPUB, { whitelist: ["npub1nsecwhitelistmember"] }),
"admin list edit must succeed with an nsec signer and no usable window.nostr",
);
assert.ok(
nsecSignerCalls > 0,
"should sign admin list updates with the active (nsec) signer, not window.nostr",
);
console.log("admin-list-store tests passed");
+35
View File
@@ -204,5 +204,40 @@ describe("AuthService Coverage", () => {
// So yes, moved to front.
assert.equal(saved[0].pubkey, pubkey1);
});
// #48: fluent switching across signing methods — switchProfile must forward the
// per-method restore options to the provider.
test("forwards unlockStored + passphrase to the provider (nsec switch)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000a1";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nsec" }));
await authService.switchProfile(target, {
providerId: "nsec",
unlockStored: true,
passphrase: "pin1234",
});
const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
assert.equal(opts.unlockStored, true);
assert.equal(opts.passphrase, "pin1234");
});
test("forwards reuseStored to the provider (NIP-46 switch)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000b2";
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nip46" }));
await authService.switchProfile(target, { providerId: "nip46", reuseStored: true });
const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options;
assert.equal(opts.reuseStored, true);
});
test("rejects and does NOT activate a DIFFERENT account than requested (expectPubkey)", async () => {
const target = "00000000000000000000000000000000000000000000000000000000000000c3";
const other = "00000000000000000000000000000000000000000000000000000000000000d4";
// A single-slot stored session that resolves to another account:
mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: other, authType: "nip46" }));
await assert.rejects(
() => authService.switchProfile(target, { providerId: "nip46", reuseStored: true }),
{ code: "pubkey-mismatch" },
);
assert.notEqual(getPubkey(), other, "must not silently switch to the wrong account");
});
});
});
+65
View File
@@ -0,0 +1,65 @@
// Channel-grid relay fetch is bounded (raceListWithTimeout): a hung relay (one
// that never sends EOSE) must resolve to "no events" instead of stalling the
// whole grid render. A failing relay likewise contributes [] rather than
// throwing, and a late rejection after the timeout can't surface as unhandled.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-channel-relay-timeout
// given: "a relay list() that resolves / hangs / rejects / returns non-array"
// when: "raceListWithTimeout wraps it with a short timeout"
// then: "success returns the array; hang/reject/garbage resolve to []"
// observable_outcomes:
// - "a fast success passes the array through unchanged"
// - "a hang resolves to [] within the timeout bound (does not stall)"
// - "a rejection resolves to [] (does not throw)"
// determinism_controls:
// - "explicit short timeoutMs; hang modeled as a never-resolving promise"
// anti_cheat_rationale:
// prevents: ["retry/sleep-based flake masking", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { raceListWithTimeout } from "../js/channelProfile.js";
test("passes a fast successful result through unchanged", async () => {
const events = [{ id: "a" }, { id: "b" }];
const result = await raceListWithTimeout(async () => events, 1000);
assert.deepEqual(result, events);
});
test("a hung relay resolves to [] within the timeout instead of stalling", async () => {
const started = Date.now();
// Never resolves — models a relay that never sends EOSE.
const result = await raceListWithTimeout(() => new Promise(() => {}), 30);
assert.deepEqual(result, []);
assert.ok(Date.now() - started < 500, "must resolve promptly via the timeout");
});
test("a rejecting relay resolves to [] rather than throwing", async () => {
const result = await raceListWithTimeout(async () => {
throw new Error("relay exploded");
}, 1000);
assert.deepEqual(result, []);
});
test("a non-array result is normalized to []", async () => {
const result = await raceListWithTimeout(async () => null, 1000);
assert.deepEqual(result, []);
});
test("a late rejection after the timeout wins does not throw (no unhandled)", async () => {
// The list rejects AFTER the timeout has already resolved to []. The helper
// must swallow it so it can't surface as an unhandled rejection.
const result = await raceListWithTimeout(
() => new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 40)),
10,
);
assert.deepEqual(result, []);
// Give the late rejection a tick to (not) blow up.
await new Promise((resolve) => setTimeout(resolve, 60));
});
+75
View File
@@ -0,0 +1,75 @@
// showConfirm() replaces native window.confirm with a styled, promise-based dialog so
// all confirmations live in the app's UI system. It must resolve true on Confirm, false
// on Cancel/backdrop/Escape, clean up its DOM, and degrade gracefully without a DOM.
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { showConfirm } from "../js/ui/confirmDialog.js";
let dom;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.document = dom.window.document;
globalThis.window = dom.window;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
});
afterEach(() => {
delete globalThis.document;
delete globalThis.window;
delete globalThis.HTMLElement;
delete globalThis.KeyboardEvent;
});
function overlay() {
return dom.window.document.querySelector(".bv-modal");
}
test("renders a dialog with the message and resolves TRUE when Confirm is clicked", async () => {
const p = showConfirm("Delete this thing?", { confirmLabel: "Delete", danger: true });
const modal = overlay();
assert.ok(modal, "an overlay was added to the DOM");
assert.match(modal.textContent, /Delete this thing\?/);
modal.querySelector(".btn").click(); // confirm button (cancel is .btn-ghost)
assert.equal(await p, true);
assert.equal(overlay(), null, "overlay removed after resolving");
});
test("resolves FALSE when Cancel is clicked", async () => {
const p = showConfirm("Sure?");
overlay().querySelector(".btn-ghost").click();
assert.equal(await p, false);
assert.equal(overlay(), null);
});
test("resolves FALSE when the backdrop is clicked", async () => {
const p = showConfirm("Sure?");
overlay().querySelector(".bv-modal-backdrop").click();
assert.equal(await p, false);
});
test("resolves FALSE on Escape", async () => {
const p = showConfirm("Sure?");
dom.window.document.dispatchEvent(
new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
assert.equal(await p, false);
assert.equal(overlay(), null);
});
test("a danger prompt focuses Cancel (the safe action) by default", async () => {
const p = showConfirm("Delete?", { danger: true });
assert.equal(
dom.window.document.activeElement,
overlay().querySelector(".btn-ghost"),
);
overlay().querySelector(".btn-ghost").click();
await p;
});
test("without a DOM it declines rather than throwing", async () => {
delete globalThis.document;
delete globalThis.window;
assert.equal(await showConfirm("anything"), false);
});
+134
View File
@@ -0,0 +1,134 @@
// The account-switch / reload signer gate (app.ensureEncryptionCapableSigner)
// decides whether a signing/encryption action can proceed, must fail, or should
// prompt the user to re-unlock a persisted nsec key. decideSignerEnsure is the
// pure branching behind it (TODO #51/#54/#56/#57).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-signer-ensure-decision
// given: "current signer capabilities + whether a locked nsec key exists"
// when: "decideSignerEnsure runs"
// then: "ok when capable, prompt when recoverable, fail with a precise reason otherwise"
// observable_outcomes:
// - "capable encrypt signer -> {action:ok}"
// - "no signer + no pubkey -> fail:no-signer"
// - "incapable + no stored key -> fail:no-stored-key"
// - "incapable + stored key for a DIFFERENT pubkey -> fail:pubkey-mismatch"
// - "incapable + matching locked key -> prompt"
// - "need:sign only requires signEvent (nip44/nip04 not required)"
// determinism_controls:
// - "pure function; explicit inputs; injected normalizePubkey"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
decideSignerEnsure,
isSignerCapable,
} from "../js/nostr/ensureSignerDecision.js";
const PUB = "a".repeat(64);
const OTHER = "b".repeat(64);
const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
test("capable encrypt-signer proceeds", () => {
const d = decideSignerEnsure({
capabilities: { sign: true, nip44: true, nip04: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "ok" });
});
test("nip04-only signer is still encrypt-capable", () => {
assert.equal(isSignerCapable({ sign: true, nip04: true }, "encrypt"), true);
});
test("a sign-only signer is NOT encrypt-capable but IS sign-capable", () => {
const caps = { sign: true, nip44: false, nip04: false };
assert.equal(isSignerCapable(caps, "encrypt"), false);
assert.equal(isSignerCapable(caps, "sign"), true);
// need:sign proceeds even without any encryption method
assert.deepEqual(
decideSignerEnsure({
capabilities: caps,
need: "sign",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
}),
{ action: "ok" },
);
});
test("no signer and no pubkey -> fail:no-signer", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: "",
storedKeyMeta: null,
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-signer" });
});
test("incapable but no stored nsec key -> fail:no-stored-key (caller surfaces its own error)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: null,
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
});
test("stored key belongs to a DIFFERENT account -> fail:pubkey-mismatch (never unlock the wrong key)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: OTHER },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "pubkey-mismatch" });
});
test("incapable + matching locked nsec key -> prompt to re-unlock", () => {
const d = decideSignerEnsure({
capabilities: { sign: true, nip44: false, nip04: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true, pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "prompt" });
});
test("stored key with no pubkey field still prompts (metadata may omit it)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: true },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "prompt" });
});
test("hasEncryptedKey must be strictly true (a falsey/omitted flag never prompts)", () => {
const d = decideSignerEnsure({
capabilities: { sign: false },
need: "encrypt",
normalizedPubkey: PUB,
storedKeyMeta: { hasEncryptedKey: "yes", pubkey: PUB },
normalizePubkey: norm,
});
assert.deepEqual(d, { action: "fail", reason: "no-stored-key" });
});
+132
View File
@@ -0,0 +1,132 @@
// spreadAuthors() is a gentle diversity re-rank: it keeps the caller's ranked
// order as the priority but breaks up runs of the same creator so a feed tab
// doesn't read as walls of one uploader. The #1 ranked item is always chosen
// first (so "best-fit"/"most-viewed" stays on top), and each author's items keep
// their relative ranked order.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-spread-authors
// given: "a ranked list with clustered authors"
// when: "spreadAuthors re-ranks it (window 1)"
// then: "top item is unchanged, no avoidable adjacent same-author, order-within-author preserved"
// observable_outcomes:
// - "result[0] === input[0] (top rank preserved)"
// - "no two consecutive items share an author when another author remains"
// - "same-author items keep their input relative order"
// determinism_controls:
// - "pure function; fixed input arrays; no clock/network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "snapshot rubber-stamping"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { spreadAuthors } from "../../js/feedEngine/sorters.js";
const item = (id, pubkey) => ({ video: { id, pubkey } });
const ids = (list) => list.map((i) => i.video.id);
const authorsOf = (list) => list.map((i) => i.video.pubkey);
function hasAvoidableAdjacentDupe(list) {
const authors = authorsOf(list);
const distinct = new Set(authors).size;
if (distinct <= 1) return false; // unavoidable
for (let i = 1; i < authors.length; i += 1) {
if (authors[i] && authors[i] === authors[i - 1]) {
return true;
}
}
return false;
}
test("keeps the #1 ranked item on top", () => {
const input = [
item("a1", "A"),
item("a2", "A"),
item("b1", "B"),
item("c1", "C"),
];
const out = spreadAuthors(input);
assert.equal(out[0].video.id, "a1", "highest-ranked item stays first");
});
test("breaks up a run of one author into non-adjacent slots", () => {
// Ranked order clusters author A at the top.
const input = [
item("a1", "A"),
item("a2", "A"),
item("a3", "A"),
item("b1", "B"),
item("c1", "C"),
];
const out = spreadAuthors(input);
assert.equal(hasAvoidableAdjacentDupe(out), false, "no avoidable adjacent same-author");
assert.equal(out.length, input.length, "no items dropped or duplicated");
assert.deepEqual(ids(out).sort(), ids(input).sort(), "same set of items");
});
test("preserves each author's relative ranked order", () => {
const input = [
item("a1", "A"),
item("a2", "A"),
item("b1", "B"),
item("a3", "A"),
item("b2", "B"),
];
const out = ids(spreadAuthors(input));
// A's items stay in a1 < a2 < a3 order; B's in b1 < b2 order.
assert.ok(out.indexOf("a1") < out.indexOf("a2"), "a1 before a2");
assert.ok(out.indexOf("a2") < out.indexOf("a3"), "a2 before a3");
assert.ok(out.indexOf("b1") < out.indexOf("b2"), "b1 before b2");
});
test("all-one-author input is returned in original order", () => {
const input = [item("a1", "A"), item("a2", "A"), item("a3", "A")];
assert.deepEqual(ids(spreadAuthors(input)), ["a1", "a2", "a3"]);
});
test("lists shorter than 3 are returned unchanged (copy)", () => {
const input = [item("a1", "A"), item("a2", "A")];
const out = spreadAuthors(input);
assert.deepEqual(ids(out), ["a1", "a2"]);
assert.notEqual(out, input, "returns a copy, not the same reference");
});
test("anonymous authors are allowed to sit adjacent (not treated as repeats)", () => {
const input = [
item("x1", ""),
item("x2", ""),
item("x3", ""),
];
// No real author to diversify against → order preserved, no infinite defer.
assert.deepEqual(ids(spreadAuthors(input)), ["x1", "x2", "x3"]);
});
test("window:2 spreads a clustered author across the feed (tail dupes only when forced)", () => {
// A is 50% of the list; a larger window pushes A's items further apart.
const input = [
item("a1", "A"),
item("a2", "A"),
item("a3", "A"),
item("b1", "B"),
item("c1", "C"),
item("d1", "D"),
];
const out = authorsOf(spreadAuthors(input, { window: 2 }));
assert.equal(out[0], "A", "top item preserved");
// No adjacent dupes (always achievable here since no author exceeds 50%).
for (let i = 1; i < out.length; i += 1) {
assert.notEqual(out[i], out[i - 1], `no adjacent dupe at ${i}`);
}
// A's three items are genuinely spread out, not stacked at the front.
const aPositions = out
.map((author, idx) => (author === "A" ? idx : -1))
.filter((idx) => idx >= 0);
assert.ok(
aPositions[aPositions.length - 1] - aPositions[0] >= 4,
"A's items span most of the feed rather than clustering",
);
});
+86
View File
@@ -0,0 +1,86 @@
// Upload-modal hashtag suggestions (TODO #45): rank a user's own past hashtags by
// how many of their videos use each, for one-tap reuse chips.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-hashtag-suggestions-rank
// given: "a user's video objects with nip71.hashtags / nip71.t / raw t tags"
// when: "rankHashtagsByFrequency runs"
// then: "tags are normalized, deduped per video, ranked by video-count then name"
// observable_outcomes:
// - "count = number of videos using the tag (once per video)"
// - "case/#-prefix normalized and merged"
// - "bitvid's fixed t=video marker excluded from raw tags (but a real 'video' hashtag counts)"
// - "ties broken alphabetically; limit respected; empty input -> []"
// determinism_controls:
// - "pure function; explicit inputs"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
rankHashtagsByFrequency,
extractVideoHashtags,
} from "../js/utils/hashtagSuggestions.js";
test("counts videos-per-tag, normalizing case and the # prefix", () => {
const ranked = rankHashtagsByFrequency([
{ nip71: { hashtags: ["Nostr", "Bitcoin"] } },
{ nip71: { hashtags: ["nostr", "#bitcoin"] } },
{ nip71: { hashtags: ["NOSTR"] } },
]);
assert.deepEqual(ranked, [
{ tag: "nostr", count: 3 },
{ tag: "bitcoin", count: 2 },
]);
});
test("a tag repeated within ONE video counts once for that video", () => {
const ranked = rankHashtagsByFrequency([
{ nip71: { hashtags: ["nostr", "nostr", "#Nostr"] } },
]);
assert.deepEqual(ranked, [{ tag: "nostr", count: 1 }]);
});
test("falls back to raw event t tags and excludes the fixed 'video' marker", () => {
assert.deepEqual(
extractVideoHashtags({
tags: [
["t", "video"],
["t", "bitcoin"],
["d", "abc"],
["t", ""],
],
}),
["bitcoin"],
);
// But a genuine nip71 "video" hashtag is NOT stripped (only the raw marker is).
const ranked = rankHashtagsByFrequency([{ nip71: { hashtags: ["video"] } }]);
assert.deepEqual(ranked, [{ tag: "video", count: 1 }]);
});
test("ties break alphabetically and limit is respected", () => {
const ranked = rankHashtagsByFrequency(
[
{ nip71: { hashtags: ["zebra", "apple", "mango"] } },
{ nip71: { hashtags: ["zebra", "apple"] } },
{ nip71: { hashtags: ["apple"] } },
],
{ limit: 2 },
);
// apple:3, zebra:2, mango:1 -> top 2
assert.deepEqual(ranked, [
{ tag: "apple", count: 3 },
{ tag: "zebra", count: 2 },
]);
});
test("empty / malformed input yields an empty list", () => {
assert.deepEqual(rankHashtagsByFrequency([]), []);
assert.deepEqual(rankHashtagsByFrequency(null), []);
assert.deepEqual(rankHashtagsByFrequency([{}, { nip71: {} }, { tags: "nope" }]), []);
});
+151
View File
@@ -0,0 +1,151 @@
// Sending a NIP-17 DM (kind 1059 gift wrap) must generate an ephemeral wrapper
// keypair. That keygen hex-encodes the generated secret via bytesToHex; a
// missing import made bytesToHex throw (swallowed into "") so keygen returned
// null and every NIP-17 send failed with `nip17-keygen-failed`. The legacy
// (kind 4 / NIP-04) send tests delete nip44Encrypt, so they never exercised
// this path. This test forces the NIP-17 path and asserts a gift wrap is built
// and published.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nip17-send-gift-wrap
// given: "a nip44-capable signer and known DM relays"
// when: "sendDirectMessage is called with useNip17:true"
// then: "it resolves ok and publishes a kind-1059 gift wrap (no keygen failure)"
// observable_outcomes:
// - "result.ok === true (not { error: 'nip17-keygen-failed' })"
// - "a kind 1059 gift-wrap event is published to the recipient relay"
// determinism_controls:
// - "real nostr-tools injected; pool.publish/list stubbed; relay hints supplied"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import assert from "node:assert/strict";
import test, { after } from "node:test";
const RELAY_URL = "wss://relay.nip17.test";
after(() => {
setTimeout(() => process.exit(0), 100);
});
test("sendDirectMessage (NIP-17) builds and publishes a kind-1059 gift wrap", async () => {
const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
const previousNostrTools = globalThis.NostrTools;
const previousReady = globalThis.nostrToolsReady;
const nostrTools = await import("nostr-tools");
const canonicalTools = { ...nostrTools };
try {
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools;
globalThis.NostrTools = canonicalTools;
globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: canonicalTools });
const [{ nostrClient }, { getActiveSigner, clearActiveSigner }] =
await Promise.all([
import("../js/nostrClientFacade.js"),
import("../js/nostr/client.js"),
]);
const previousPool = nostrClient.pool;
const previousRelays = nostrClient.relays;
const previousPubkey = nostrClient.pubkey;
const publishInvocations = [];
nostrClient.pool = {
publish(urls, event) {
publishInvocations.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") setTimeout(() => handler(), 0);
return true;
},
};
},
list() {
return Promise.resolve([]);
},
};
nostrClient.relays = [RELAY_URL];
nostrClient.readRelays = [RELAY_URL];
nostrClient.writeRelays = [RELAY_URL];
const senderSecret = canonicalTools.generateSecretKey();
const senderPubkey = canonicalTools.getPublicKey(senderSecret);
const recipientSecret = canonicalTools.generateSecretKey();
const recipientPubkey = canonicalTools.getPublicKey(recipientSecret);
await nostrClient.registerPrivateKeySigner({
privateKey: canonicalTools.utils.bytesToHex(senderSecret),
pubkey: senderPubkey,
});
nostrClient.pubkey = senderPubkey;
const signer = getActiveSigner();
// Keep nip44 (so the NIP-17 path is taken) and give it a working impl.
signer.signEvent = (event) => {
const finalized = canonicalTools.finalizeEvent(event, senderSecret);
return { ...event, id: finalized.id, sig: finalized.sig };
};
signer.nip44Encrypt = async (peerPubkey, plaintext) => {
const key = canonicalTools.nip44.v2.utils.getConversationKey(
senderSecret,
peerPubkey,
);
return canonicalTools.nip44.v2.encrypt(plaintext, key);
};
const result = await nostrClient.sendDirectMessage(
recipientPubkey,
"nip17 gift wrap smoke test",
{
useNip17: true,
recipientRelayHints: [RELAY_URL],
senderRelayHints: [RELAY_URL],
},
);
assert.equal(
result.ok,
true,
`NIP-17 send should succeed, got: ${JSON.stringify(result)}`,
);
assert.notEqual(
result.error,
"nip17-keygen-failed",
"ephemeral wrapper keygen must not fail",
);
const giftWraps = publishInvocations.filter(
({ event }) => event?.kind === 1059,
);
assert.ok(
giftWraps.length >= 1,
"at least one kind-1059 gift wrap should be published",
);
// A gift wrap is signed by a fresh ephemeral key, never the sender's.
assert.notEqual(
giftWraps[0].event.pubkey,
senderPubkey,
"gift wrap must be authored by the ephemeral wrapper key, not the sender",
);
signer.signEvent = undefined;
signer.nip44Encrypt = undefined;
clearActiveSigner();
nostrClient.pool = previousPool;
nostrClient.relays = previousRelays;
nostrClient.pubkey = previousPubkey;
} finally {
if (previousCanonical === undefined) delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
else globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical;
if (previousNostrTools === undefined) delete globalThis.NostrTools;
else globalThis.NostrTools = previousNostrTools;
if (previousReady === undefined) delete globalThis.nostrToolsReady;
else globalThis.nostrToolsReady = previousReady;
}
});
+127
View File
@@ -0,0 +1,127 @@
// Per-account NIP-46 remote-signer session storage: several saved NIP-46
// accounts each keep their own reconnectable session so the profile switcher
// can move between them. Before this, a single slot (bitvid:nip46:session:v1)
// meant only the last-connected session survived, so switching among saved
// NIP-46 accounts silently reconnected the wrong one (or failed).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nip46-multi-account-storage
// given: "two NIP-46 accounts each persist a stored session"
// when: "the store is read by user pubkey / cleared for one account"
// then: "each account resolves its own session; clearing one keeps the other"
// observable_outcomes:
// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays"
// - "clearStoredNip46Session(pubkey) removes only the named account"
// - "the legacy v1 single slot migrates into the per-pubkey map on read"
// determinism_controls:
// - "in-memory localStorage polyfill; no network/clock dependence"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import {
writeStoredNip46SessionSync,
readStoredNip46Session,
clearStoredNip46Session,
listStoredNip46SessionPubkeys,
} from "../js/nostr/nip46Client.js";
const USER_A =
"00000000000000000000000000000000000000000000000000000000000000a1";
const USER_B =
"00000000000000000000000000000000000000000000000000000000000000b2";
const REMOTE_A =
"1111111111111111111111111111111111111111111111111111111111111111";
const REMOTE_B =
"2222222222222222222222222222222222222222222222222222222222222222";
const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1";
const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2";
function session(userPubkey, remotePubkey, relay) {
return {
version: 1,
clientPublicKey:
"3333333333333333333333333333333333333333333333333333333333333333",
remotePubkey,
relays: [relay],
encryption: "nip44.v2",
userPubkey,
lastConnectedAt: 1700000000000,
encryptedSecrets: "cipher",
keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
};
}
beforeEach(() => {
localStorage.clear();
});
test("each saved account resolves its own session", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]);
});
test("lists every account that has a stored session", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort());
});
test("clearing one account keeps the others reconnectable", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
clearStoredNip46Session(USER_A);
assert.equal(readStoredNip46Session(USER_A), null);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
});
test("no-arg read returns the last-connected account as the default", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B);
});
test("no-arg clear wipes every stored session (logout)", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
clearStoredNip46Session();
assert.deepEqual(listStoredNip46SessionPubkeys(), []);
assert.equal(readStoredNip46Session(USER_A), null);
assert.equal(readStoredNip46Session(), null);
});
test("a legacy v1 single-slot session migrates into the per-pubkey map", () => {
// Simulate an install that only ever had the old single slot.
localStorage.setItem(
NIP46_SESSION_STORAGE_KEY,
JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")),
);
assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null);
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]);
// A second account can now be added alongside the migrated one.
writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example"));
assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A);
assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B);
});
test("requesting an account with no stored session returns null (not a wrong one)", () => {
writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example"));
assert.equal(readStoredNip46Session(USER_B), null);
});
+112
View File
@@ -0,0 +1,112 @@
// Optimistic feed insert after publishing (#46): a just-published video is
// added to the client's active-video cache immediately so it shows in the feed
// without waiting for the relays to echo it back — while still obeying the same
// dedupe / active-key rules as live ingestion, so a relay copy of the same
// event can't duplicate it and an older revision can't overwrite a newer one.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-optimistic-feed-insert
// given: "a freshly signed video event"
// when: "ingestLocalVideoEvent is called with it"
// then: "getActiveVideos includes it exactly once, newest-revision-wins"
// observable_outcomes:
// - "getActiveVideos() surfaces the ingested video"
// - "re-ingesting the same event does not create a duplicate active entry"
// - "an older revision of the same root does not replace a newer active one"
// - "an invalid event returns null and inserts nothing"
// determinism_controls:
// - "in-memory NostrClient + localStorage polyfill; explicit created_at values"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { NostrClient } from "../js/nostr/client.js";
const PUB = "f".repeat(64);
function videoEvent({ id, root, title, createdAt }) {
return {
id,
pubkey: PUB,
kind: 30078,
created_at: createdAt,
content: JSON.stringify({
version: 3,
title,
url: "https://cdn.example/v.mp4",
videoRootId: root,
}),
tags: [["d", root]],
};
}
test("a published event is surfaced in the active feed immediately", () => {
const client = new NostrClient();
const video = client.ingestLocalVideoEvent(
videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }),
);
assert.ok(video, "returns the ingested video");
const active = client.getActiveVideos();
assert.equal(active.length, 1);
assert.equal(active[0].id, "evt-1");
assert.equal(active[0].title, "Hello");
});
test("re-ingesting the same event does not duplicate it in the feed", () => {
const client = new NostrClient();
const evt = videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 });
client.ingestLocalVideoEvent(evt);
client.ingestLocalVideoEvent(evt);
assert.equal(client.getActiveVideos().length, 1, "same event stays a single active entry");
});
test("a newer revision of the same root replaces the active entry", () => {
const client = new NostrClient();
client.ingestLocalVideoEvent(
videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }),
);
client.ingestLocalVideoEvent(
videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }),
);
const active = client.getActiveVideos();
assert.equal(active.length, 1, "one active entry per root");
assert.equal(active[0].title, "New", "newest revision wins");
});
test("an older revision does NOT overwrite a newer active entry", () => {
const client = new NostrClient();
client.ingestLocalVideoEvent(
videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }),
);
// A late-arriving older copy (e.g. a stale relay echo) must not clobber it.
client.ingestLocalVideoEvent(
videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }),
);
const active = client.getActiveVideos();
assert.equal(active.length, 1);
assert.equal(active[0].title, "New", "older revision must not displace the newer active entry");
});
test("an invalid event returns null and inserts nothing", () => {
const client = new NostrClient();
const result = client.ingestLocalVideoEvent({ id: "bad", content: "not-json", tags: [] });
assert.equal(result, null);
assert.equal(client.getActiveVideos().length, 0);
});
test("a null/garbage argument is handled without throwing", () => {
const client = new NostrClient();
assert.equal(client.ingestLocalVideoEvent(null), null);
assert.equal(client.ingestLocalVideoEvent({}), null);
assert.equal(client.getActiveVideos().length, 0);
});
+71
View File
@@ -0,0 +1,71 @@
// showPasswordPrompt() collects a passphrase/PIN via a styled dialog (used to unlock a
// stored nsec key when switching accounts). Resolves the entered string, or null when
// the user cancels/escapes.
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { showPasswordPrompt } from "../js/ui/promptDialog.js";
let dom;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.document = dom.window.document;
globalThis.window = dom.window;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
});
afterEach(() => {
delete globalThis.document;
delete globalThis.window;
delete globalThis.HTMLElement;
delete globalThis.KeyboardEvent;
});
const overlay = () => dom.window.document.querySelector(".bv-modal");
const input = () => overlay().querySelector('input[type="password"]');
test("resolves the entered value on Confirm and removes the dialog", async () => {
const p = showPasswordPrompt("Enter your PIN", { confirmLabel: "Switch" });
input().value = "hunter2";
overlay().querySelector(".btn").click(); // confirm (cancel is .btn-ghost)
assert.equal(await p, "hunter2");
assert.equal(overlay(), null, "dialog removed after resolving");
});
test("resolves the value when Enter is pressed in the input", async () => {
const p = showPasswordPrompt("PIN");
const el = input();
el.value = "s3cret";
el.dispatchEvent(
new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
assert.equal(await p, "s3cret");
});
test("resolves null when cancelled", async () => {
const p = showPasswordPrompt("PIN");
overlay().querySelector(".btn-ghost").click();
assert.equal(await p, null);
});
test("resolves null on Escape", async () => {
const p = showPasswordPrompt("PIN");
dom.window.document.dispatchEvent(
new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
assert.equal(await p, null);
});
test("focuses the input so the user can type immediately", async () => {
const p = showPasswordPrompt("PIN");
assert.equal(dom.window.document.activeElement, input());
overlay().querySelector(".btn-ghost").click();
await p;
});
test("without a DOM it resolves null rather than throwing", async () => {
delete globalThis.document;
delete globalThis.window;
assert.equal(await showPasswordPrompt("PIN"), null);
});
+124
View File
@@ -0,0 +1,124 @@
// Admin pane "Blocked videos" sub-tab (#25 follow-up): the per-event admin block
// list is now viewable/removable in the UI. These cover the row-building (pure)
// and the list rendering + unblock wiring (jsdom).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-blocked-videos-rows
// given: "a raw blocked-id list and an optional cache resolver"
// when: "buildBlockedVideoRows / renderBlockedVideosList run"
// then: "rows dedupe + enrich, the list renders, and Unblock invokes the callback"
// observable_outcomes:
// - "id-only rows when the video isn't cached; title+id rows when it is"
// - "empty list shows the empty state and hides the <ul>"
// - "clicking Unblock calls onUnblock with the event id"
// determinism_controls:
// - "pure fn for row building; jsdom for render; no network/clock"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "snapshot rubber-stamping"]
// relaxation:
// did_relax_any_assertion: false
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import {
buildBlockedVideoRows,
renderBlockedVideosList,
shortenEventId,
} from "../js/ui/profileModal/blockedVideosSection.js";
const HEX = "a".repeat(64);
const HEX2 = "b".repeat(64);
test("buildBlockedVideoRows: id-only row when the video isn't cached", () => {
const rows = buildBlockedVideoRows([HEX], () => null);
assert.equal(rows.length, 1);
assert.equal(rows[0].id, HEX);
assert.equal(rows[0].title, "");
assert.equal(rows[0].label, shortenEventId(HEX), "label falls back to shortened id");
assert.equal(rows[0].sublabel, "", "no sublabel without a title");
});
test("buildBlockedVideoRows: enriches with title + author when cached", () => {
const rows = buildBlockedVideoRows([HEX], (id) =>
id === HEX ? { title: "My Clip", pubkey: HEX2 } : null,
);
assert.equal(rows[0].title, "My Clip");
assert.equal(rows[0].label, "My Clip", "title becomes the primary label");
assert.equal(rows[0].sublabel, shortenEventId(HEX), "id demoted to sublabel");
assert.equal(rows[0].author, HEX2);
});
test("buildBlockedVideoRows: dedupes, lowercases, and drops blanks", () => {
const rows = buildBlockedVideoRows(
[HEX, HEX.toUpperCase(), "", " ", HEX2],
() => null,
);
assert.deepEqual(
rows.map((r) => r.id),
[HEX, HEX2],
"one row per distinct id, blanks removed",
);
});
test("buildBlockedVideoRows: a throwing resolver degrades to an id-only row", () => {
const rows = buildBlockedVideoRows([HEX], () => {
throw new Error("cache miss");
});
assert.equal(rows.length, 1);
assert.equal(rows[0].label, shortenEventId(HEX));
});
describe_render();
function describe_render() {
let dom;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
});
afterEach(() => {
delete globalThis.document;
delete globalThis.HTMLElement;
});
const mount = () => {
const list = dom.window.document.createElement("ul");
const empty = dom.window.document.createElement("div");
dom.window.document.body.append(list, empty);
return { list, empty };
};
test("renderBlockedVideosList: empty shows the empty state and hides the list", () => {
const { list, empty } = mount();
renderBlockedVideosList(list, empty, []);
assert.equal(empty.classList.contains("hidden"), false, "empty state visible");
assert.equal(list.classList.contains("hidden"), true, "list hidden");
assert.equal(list.children.length, 0);
});
test("renderBlockedVideosList: renders one row per blocked video", () => {
const { list, empty } = mount();
const rows = buildBlockedVideoRows([HEX, HEX2], () => null);
renderBlockedVideosList(list, empty, rows, { onUnblock: () => {} });
assert.equal(empty.classList.contains("hidden"), true, "empty hidden");
assert.equal(list.children.length, 2, "two rows rendered");
assert.equal(list.children[0].dataset.eventId, HEX);
});
test("renderBlockedVideosList: clicking Unblock calls onUnblock with the id", () => {
const { list, empty } = mount();
const rows = buildBlockedVideoRows([HEX], () => null);
const calls = [];
renderBlockedVideosList(list, empty, rows, {
onUnblock: (id) => calls.push(id),
});
const button = list.querySelector("button");
assert.ok(button, "row has an Unblock button");
button.click();
assert.deepEqual(calls, [HEX], "onUnblock invoked with the blocked id");
});
}
+296 -2
View File
@@ -1158,9 +1158,26 @@ test('admin mutations invoke accessControl stubs and update admin DOM', async ()
assert.ok(controller.navButtons.admin);
assert.equal(controller.navButtons.admin.classList.contains('hidden'), false);
assert.equal(controller.moderatorSection.classList.contains('hidden'), false);
// The admin pane is now organized into sub-tabs: Whitelist is the default tab
// (visible), while Moderators/Blacklist/Blocked-videos sections are hidden
// until their tab is selected. The Moderators *tab button* is shown to a
// super admin. (Previously every section was stacked and visible at once.)
const admin = controller.adminController;
assert.equal(admin.whitelistSection.classList.contains('hidden'), false);
assert.equal(admin.moderatorSection.classList.contains('hidden'), true);
assert.equal(
admin.subtabButtons.moderators.classList.contains('hidden'),
false,
'moderators sub-tab is available to a super admin',
);
// Content still populates behind the inactive tab.
assert.equal(controller.adminModeratorList.querySelectorAll('li').length, 1);
assert.equal(controller.adminModeratorList.hasAttribute('hidden'), false);
// Selecting the Moderators sub-tab reveals its section and hides Whitelist.
admin.selectAdminSubtab('moderators');
assert.equal(admin.moderatorSection.classList.contains('hidden'), false);
assert.equal(admin.whitelistSection.classList.contains('hidden'), true);
// Back to Whitelist for the remaining assertions.
admin.selectAdminSubtab('whitelist');
assert.equal(
controller.whitelistList.querySelectorAll('button').length > 0,
true,
@@ -1351,3 +1368,280 @@ test('handleDirectMessagesRelayWarning suppresses status updates when disabled',
controller.handleDirectMessagesRelayWarning(detail);
assert.equal(statusCalls.length, 0);
});
// Account-switch refresh (nip-07 -> saved nsec, modal open): the modal must
// re-render for the NEW identity without a manual refresh, and a failure in one
// identity-reset step must not skip the others (notably the DM reset that clears
// the previous account's conversation).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-switch-modal-refresh
// given: "profile modal controller loaded"
// when: "handleAuthLogin runs for a new identity / refreshOpenPanesForActiveIdentity is called"
// then: "DM reset still runs after a sibling throw; the active pane is re-run when open"
// observable_outcomes:
// - "runIdentityResetStep swallows a throwing step (does not propagate)"
// - "a throwing DM reset does not skip renderSavedProfiles"
// - "refreshOpenPanesForActiveIdentity re-runs selectPane(activePane) only when open"
// determinism_controls:
// - "instance method spies; no network/timers relied upon for assertions"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
test('runIdentityResetStep isolates a throwing step (does not propagate)', async () => {
const controller = createController();
let ran = false;
assert.doesNotThrow(() => {
controller.runIdentityResetStep('boom', () => {
throw new Error('kaboom');
});
controller.runIdentityResetStep('ok', () => {
ran = true;
});
});
assert.equal(ran, true, 'a later reset step still runs after an earlier one throws');
});
test('handleAuthLogin: a throwing DM reset does not skip the saved-profiles reset', async (t) => {
const controller = createController();
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
// Keep the async collaborators inert so the test only exercises the reset order.
controller.hydrateActiveWalletSettings = async () => null;
controller.refreshAdminPaneState = async () => {};
controller.refreshOpenPanesForActiveIdentity = () => {};
let dmResetPubkey = 'unset';
controller.handleActiveDmIdentityChanged = (pubkey) => {
dmResetPubkey = pubkey;
throw new Error('dm reset blew up');
};
let savedProfilesRendered = 0;
controller.renderSavedProfiles = () => {
savedProfilesRendered += 1;
};
const pubkey = 'b'.repeat(64);
const result = await controller.handleAuthLogin({
pubkey,
activeProfilePubkey: pubkey,
identityChanged: true,
});
assert.equal(result, true);
assert.equal(dmResetPubkey, pubkey, 'DM reset was attempted for the new identity');
assert.equal(
savedProfilesRendered,
1,
'the saved-profiles reset still ran even though the DM reset threw',
);
});
test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is open', async (t) => {
const controller = createController();
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
const selected = [];
controller.selectPane = (name) => {
selected.push(name);
};
controller.getActivePane = () => 'messages';
controller.isProfileModalOpen = () => false;
controller.refreshOpenPanesForActiveIdentity();
assert.deepEqual(selected, [], 'closed modal: no pane refresh');
controller.isProfileModalOpen = () => true;
controller.refreshOpenPanesForActiveIdentity();
assert.deepEqual(
selected,
['messages'],
'open modal: re-runs the active pane so it reloads for the new identity',
);
});
// Hashtag mutation routes through the signer gate (TODO #57): a reloaded nsec
// session that lost its in-memory key must trigger a re-unlock prompt, not a
// blanket "connect a signer that supports encryption" error — and publish must
// NOT run until the signer is confirmed.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-hashtag-signer-gate
// given: "a profile modal with a mocked ensureEncryptionCapableSigner + publish"
// when: "persistHashtagPreferences runs"
// then: "publish is gated on the signer being ensured first"
// observable_outcomes:
// - "gate returns not-ok -> publish never called, throws missing-signer"
// - "gate returns ok -> publish runs once"
// determinism_controls:
// - "mocked service + gate; JSDOM controller; no network"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
test('hashtag persist: a not-ok signer gate blocks publish (no blanket dead-end)', async (t) => {
const publishCalls = [];
let ensureCalls = 0;
const controller = createController({
services: {
ensureEncryptionCapableSigner: async () => {
ensureCalls += 1;
return { ok: false, reason: 'cancelled' };
},
hashtagPreferences: {
publish: async (payload) => {
publishCalls.push(payload);
return { ok: true };
},
},
},
});
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
controller.setActivePubkey('a'.repeat(64));
await assert.rejects(
() =>
controller.hashtagController.persistHashtagPreferences({
pubkey: 'a'.repeat(64),
}),
(err) => err?.code === 'hashtag-preferences-missing-signer',
);
assert.equal(ensureCalls, 1, 'the signer gate ran before publishing');
assert.equal(publishCalls.length, 0, 'publish was NOT attempted after the gate failed');
});
test('hashtag persist: an ok signer gate lets publish proceed', async (t) => {
const publishCalls = [];
const controller = createController({
services: {
ensureEncryptionCapableSigner: async () => ({ ok: true, unlocked: true }),
hashtagPreferences: {
publish: async (payload) => {
publishCalls.push(payload);
return { ok: true };
},
},
},
});
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
controller.setActivePubkey('a'.repeat(64));
await controller.hashtagController.persistHashtagPreferences({
pubkey: 'a'.repeat(64),
});
assert.equal(publishCalls.length, 1, 'publish ran once after the gate passed');
});
// "Lock this device" control (TODO #51): only shown when there is a cached
// "keep unlocked" key to forget, and clicking it locks the active account.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-lock-now-control
// given: "a profile modal with mocked isSessionKeptUnlocked + lockKeptUnlockedSession"
// when: "updateLockNowVisibility runs / the lock button is clicked"
// then: "the button shows only when unlocked; clicking locks the active pubkey"
// observable_outcomes:
// - "kept-unlocked -> button not hidden; not-unlocked -> hidden"
// - "click -> lockKeptUnlockedSession(activePubkey) called once"
// determinism_controls:
// - "mocked services; JSDOM; no network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
test('Lock this device: shown only when the session is kept unlocked', async (t) => {
let unlocked = true;
const controller = createController({
services: {
isSessionKeptUnlocked: () => unlocked,
lockKeptUnlockedSession: () => ({ ok: true }),
},
});
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
controller.setActivePubkey('a'.repeat(64));
controller.updateLockNowVisibility();
assert.ok(
controller.lockNowButton instanceof dom.window.HTMLElement,
'the lock button exists in the modal',
);
assert.equal(
controller.lockNowButton.classList.contains('hidden'),
false,
'shown when the session is kept unlocked',
);
unlocked = false;
controller.updateLockNowVisibility();
assert.equal(
controller.lockNowButton.classList.contains('hidden'),
true,
'hidden when nothing is cached to lock',
);
});
test('Lock this device: clicking locks the active account', async (t) => {
const lockCalls = [];
const controller = createController({
services: {
isSessionKeptUnlocked: () => true,
lockKeptUnlockedSession: (pubkey) => {
lockCalls.push(pubkey);
return { ok: true };
},
},
});
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
controller.setActivePubkey('a'.repeat(64));
controller.updateLockNowVisibility();
controller.lockNowButton.click();
assert.equal(lockCalls.length, 1, 'lock ran once');
assert.equal(lockCalls[0], controller.getActivePubkey(), 'locked the active account');
});
@@ -0,0 +1,50 @@
// Switching to a saved nsec account threw "this.prepareStoredNsecSwitch is not a
// function". Root cause: bindCoordinator binds every coordinator method to the
// Application (`this` === app), so when a coordinator method calls a SIBLING via
// `this.sibling(...)` it resolves against the Application — which therefore needs
// an app-level delegator for that sibling. handleProfileSwitchRequest calls
// this.prepareStoredNsecSwitch / this.handleModerationSettingsChange /
// this.waitForIdentityRefresh; the nsec one was missing its delegator.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-profile-switch-coordinator-delegators
// given: "the Application class"
// when: "handleProfileSwitchRequest runs (bound to app) and calls siblings"
// then: "every sibling it calls exists as an app-level method/delegator"
// observable_outcomes:
// - "Application.prototype.prepareStoredNsecSwitch is a function"
// - "the other siblings it invokes are also app methods"
// determinism_controls:
// - "static prototype inspection; no network/clock/DOM"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { Application } from "../js/app.js";
// Every coordinator sibling that handleProfileSwitchRequest calls via `this.X`
// must exist as an app-level method, because bindCoordinator binds the coordinator
// methods to the Application instance.
const REQUIRED_APP_METHODS = [
"handleProfileSwitchRequest",
"prepareStoredNsecSwitch", // was missing -> the reported bug
"handleModerationSettingsChange",
"waitForIdentityRefresh",
];
for (const name of REQUIRED_APP_METHODS) {
test(`Application exposes ${name}() so the bound profile-switch flow can call it`, () => {
assert.equal(
typeof Application.prototype[name],
"function",
`Application.prototype.${name} must be a function (coordinator siblings are ` +
`bound to app; handleProfileSwitchRequest calls it via this.${name}())`,
);
});
}
+85
View File
@@ -0,0 +1,85 @@
// Like/dislike (kind 7) must re-unlock a reloaded nsec signer before publishing,
// instead of the reaction silently failing / the button appearing dead (TODO #54).
// The ReactionController runs its ensureSigner gate before publishing.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-reaction-signer-gate
// given: "a logged-in user reacting with a mocked ensureSigner + publish"
// when: "handleReaction runs"
// then: "publish is gated on the signer being ensured (sign-capable)"
// observable_outcomes:
// - "cancelled/bad-passphrase gate -> publish never called, button reset"
// - "ok gate -> publish called once with the reaction content"
// determinism_controls:
// - "fully mocked services/ui/state/callbacks; no DOM/network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import ReactionController from "../js/ui/reactionController.js";
function makeController({ ensureSigner } = {}) {
const publishCalls = [];
const reactionsSet = [];
const modal = {
setUserReaction: (r) => reactionsSet.push(r),
};
const controller = new ReactionController({
services: {
reactionCounter: {
publish: async (pointer, payload) => {
publishCalls.push({ pointer, payload });
return { ok: true };
},
},
},
ui: { getVideoModal: () => modal, showError: () => {} },
state: {
getCurrentVideo: () => ({ pubkey: "b".repeat(64) }),
getCurrentVideoPointer: () => ["a", 30078, "id"],
getCurrentVideoPointerKey: () => "pointer-key",
},
callbacks: {
isUserLoggedIn: () => true,
getPubkey: () => "b".repeat(64),
ensureSigner: ensureSigner || (async () => ({ ok: true })),
},
});
return { controller, publishCalls, reactionsSet };
}
test("reaction: a cancelled signer gate blocks publish and resets the button", async () => {
let ensureCalls = 0;
const { controller, publishCalls } = makeController({
ensureSigner: async (opts) => {
ensureCalls += 1;
assert.equal(opts.need, "sign", "reactions need signing, not encryption");
return { ok: false, reason: "cancelled" };
},
});
await controller.handleReaction({ reaction: "+" });
assert.equal(ensureCalls, 1, "the signer gate ran");
assert.equal(publishCalls.length, 0, "publish was NOT attempted after a cancelled gate");
});
test("reaction: a bad-passphrase gate blocks publish (toast already shown by the gate)", async () => {
const { controller, publishCalls } = makeController({
ensureSigner: async () => ({ ok: false, reason: "bad-passphrase" }),
});
await controller.handleReaction({ reaction: "-" });
assert.equal(publishCalls.length, 0);
});
test("reaction: an ok signer gate lets publish proceed once", async () => {
const { controller, publishCalls } = makeController({
ensureSigner: async () => ({ ok: true, unlocked: true }),
});
await controller.handleReaction({ reaction: "+" });
assert.equal(publishCalls.length, 1, "publish ran after the gate passed");
assert.equal(publishCalls[0].payload.content, "+");
});
+60
View File
@@ -0,0 +1,60 @@
// Replaceable events (hashtag prefs, subscriptions, blocks) must publish with a
// created_at STRICTLY greater than the last one, or relays reject the second of two
// quick edits as "not newer" — the "added a second disinterest, it errored but
// works after refresh" bug. nextReplaceableCreatedAt guarantees monotonicity.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-replaceable-created-at
// given: "the last-published created_at + the current clock"
// when: "nextReplaceableCreatedAt computes the next created_at"
// then: "it is strictly greater than the last, and >= now"
// observable_outcomes:
// - "same wall-clock second as the last -> last+1 (never equal)"
// - "clock ahead of last -> uses now"
// - "no prior event -> uses now"
// - "successive rapid calls strictly increase"
// determinism_controls:
// - "pure function with an injected clock"
// anti_cheat_rationale:
// prevents: ["hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { nextReplaceableCreatedAt } from "../js/nostr/replaceableCreatedAt.js";
test("same wall-clock second as the last event -> strictly greater (last+1)", () => {
assert.equal(nextReplaceableCreatedAt(1000, 1000), 1001);
assert.equal(nextReplaceableCreatedAt(1000, 999), 1001, "even a laggy clock advances");
});
test("clock ahead of the last event -> uses the clock", () => {
assert.equal(nextReplaceableCreatedAt(1000, 2000), 2000);
});
test("no prior event -> uses the clock", () => {
assert.equal(nextReplaceableCreatedAt(0, 500), 500);
assert.equal(nextReplaceableCreatedAt(undefined, 500), 500);
assert.equal(nextReplaceableCreatedAt(null, 500), 500);
});
test("rapid successive edits in the same second strictly increase", () => {
const now = 1_700_000_000;
let last = 0;
const seen = [];
for (let i = 0; i < 5; i += 1) {
last = nextReplaceableCreatedAt(last, now); // clock frozen within the same second
seen.push(last);
}
for (let i = 1; i < seen.length; i += 1) {
assert.ok(seen[i] > seen[i - 1], `edit ${i} must be strictly newer than ${i - 1}`);
}
assert.deepEqual(seen, [now, now + 1, now + 2, now + 3, now + 4]);
});
test("floors fractional inputs (created_at is unix seconds)", () => {
assert.equal(nextReplaceableCreatedAt(1000.9, 1000.4), 1001);
});
+123
View File
@@ -0,0 +1,123 @@
// Per-account nsec key storage: several saved accounts each keep their own
// encrypted key on the device so the profile switcher can move between them.
// Before this, a single slot (bitvid:sessionActor:v1) meant only the last-saved
// key survived, so switching among saved nsec accounts silently failed.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-nsec-multi-account-storage
// given: "two nsec accounts each persist their encrypted key"
// when: "the store is read by a specific pubkey / cleared for one account"
// then: "each account resolves its own key; clearing one keeps the other"
// observable_outcomes:
// - "readStoredSessionActorEntry(pubkey) returns that account's ciphertext"
// - "clearStoredSessionActor(pubkey) removes only the named account"
// - "the legacy v1 single slot migrates into the per-pubkey map on read"
// determinism_controls:
// - "in-memory localStorage polyfill; no network/clock dependence"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test, { beforeEach } from "node:test";
import { strict as assert } from "node:assert";
import {
persistSessionActor,
readStoredSessionActorEntry,
clearStoredSessionActor,
listStoredSessionActorPubkeys,
SESSION_ACTOR_STORAGE_KEY,
SESSION_ACTORS_MAP_STORAGE_KEY,
} from "../js/nostr/sessionActor.js";
const PUB_A =
"00000000000000000000000000000000000000000000000000000000000000a1";
const PUB_B =
"00000000000000000000000000000000000000000000000000000000000000b2";
function actor(pubkey, cipher) {
return {
pubkey,
privateKeyEncrypted: cipher,
encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
createdAt: 1700000000000,
};
}
beforeEach(() => {
localStorage.clear();
});
test("each saved account resolves its own encrypted key", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A");
assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
// Never leak plaintext through the read path.
assert.equal(readStoredSessionActorEntry(PUB_A).privateKey, "");
});
test("lists every account that has a stored key", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort());
});
test("clearing one account keeps the others switchable", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
clearStoredSessionActor(PUB_A);
assert.equal(readStoredSessionActorEntry(PUB_A), null);
assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
});
test("no-arg read returns the last-saved account as the default", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B");
});
test("no-arg clear wipes every stored account (logout)", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
persistSessionActor(actor(PUB_B, "cipher-B"));
clearStoredSessionActor();
assert.deepEqual(listStoredSessionActorPubkeys(), []);
assert.equal(readStoredSessionActorEntry(PUB_A), null);
assert.equal(readStoredSessionActorEntry(), null);
});
test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => {
// Simulate an install that only ever had the old single slot.
localStorage.setItem(
SESSION_ACTOR_STORAGE_KEY,
JSON.stringify({
pubkey: PUB_A,
privateKeyEncrypted: "legacy-cipher",
encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" },
createdAt: 1699999999000,
}),
);
assert.equal(localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY), null);
// Reading by the account's pubkey works and seeds the v2 map.
assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher");
assert.deepEqual(listStoredSessionActorPubkeys(), [PUB_A]);
// A second account can now be added alongside the migrated one.
persistSessionActor(actor(PUB_B, "cipher-B"));
assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher");
assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B");
});
test("requesting an account with no stored key returns null (not a wrong one)", () => {
persistSessionActor(actor(PUB_A, "cipher-A"));
assert.equal(readStoredSessionActorEntry(PUB_B), null);
});
+42
View File
@@ -11,6 +11,7 @@ import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
import { isSyncEnabled } from "../js/services/settingsSyncFlags.js";
const PUBKEY = "c".repeat(64);
@@ -147,6 +148,47 @@ test("skips items already enabled on this device", async () => {
assert.equal(walletSync.pulls, 1);
});
test("accepting the offer ENABLES ongoing sync so future saves auto-push", async () => {
// Regression: accepting used to pull once but never set the sync flag, so the
// user "restored once but stopped syncing" and the toggle showed OFF. Accepting
// means "keep this device in sync".
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const walletSync = makeSyncStub({ remote: false });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
assert.equal(isSyncEnabled(PUBKEY, "storage"), false, "not enabled before accept");
const result = await prompt.maybeOffer(PUBKEY);
assert.deepEqual(result.restored, ["storage"]);
assert.equal(
isSyncEnabled(PUBKEY, "storage"),
true,
"accepting must enable ongoing sync for the restored item",
);
assert.equal(
isSyncEnabled(PUBKEY, "wallet"),
false,
"an item that was not restored must not be enabled",
);
});
test("declining the offer does NOT enable sync", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync: makeSyncStub({ remote: false }),
confirm: () => false,
});
await prompt.maybeOffer(PUBKEY);
assert.equal(isSyncEnabled(PUBKEY, "storage"), false, "decline must not enable sync");
});
test("nothing remote: does NOT consume the one-time offer (can prompt later)", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: false });
+25
View File
@@ -277,4 +277,29 @@ describe("StorageService", () => {
/Storage is locked/,
);
});
// hasStoredAccount lets the upload modal auto-unlock EXISTING storage on refresh
// without unlock() creating a fresh account for users who never set it up (#51).
test("hasStoredAccount() is false before setup, true after, and never creates an account", async () => {
assert.strictEqual(
await storageService.hasStoredAccount(pubkey),
false,
"no account before any unlock",
);
// A read-only existence check must not create an account.
assert.strictEqual(await storageService.hasStoredAccount(pubkey), false);
await storageService.unlock(pubkey, { signer: mockSigner });
assert.strictEqual(
await storageService.hasStoredAccount(pubkey),
true,
"account exists after unlock persisted the encrypted master key",
);
// Survives a lock (existence is on-disk, not the in-memory master key).
storageService.lock(pubkey);
assert.strictEqual(await storageService.hasStoredAccount(pubkey), true);
assert.strictEqual(await storageService.hasStoredAccount(""), false);
});
});
+49
View File
@@ -0,0 +1,49 @@
// #48: switching to a saved nsec account must prompt for the PIN and unlock its stored
// key (switchProfile alone threw "secret-required"). evaluateStoredNsecSwitch is the
// pure gate: proceed to prompt when a matching stored nsec key exists; error otherwise.
import test from "node:test";
import { strict as assert } from "node:assert";
import { evaluateStoredNsecSwitch } from "../js/app/storedNsecSwitch.js";
const A = "a".repeat(64);
const B = "b".repeat(64);
const nsecMeta = (pubkey = A) => ({ hasEncryptedKey: true, pubkey, source: "nsec" });
test("prompts when a stored nsec key matches the target account", () => {
assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), A), { action: "prompt" });
});
test("prompts when the stored key has no pubkey to compare (can't rule it out)", () => {
assert.deepEqual(
evaluateStoredNsecSwitch({ hasEncryptedKey: true, source: "nsec" }, A),
{ action: "prompt" },
);
});
test("errors when there is no stored session at all", () => {
assert.deepEqual(evaluateStoredNsecSwitch(null, A), {
action: "error",
reason: "no-stored-key",
});
});
test("errors when the stored key isn't an nsec (e.g. left-over non-nsec metadata)", () => {
assert.deepEqual(
evaluateStoredNsecSwitch({ hasEncryptedKey: true, pubkey: A, source: "extension" }, A),
{ action: "error", reason: "no-stored-key" },
);
});
test("errors when the stored key belongs to a DIFFERENT account", () => {
assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), B), {
action: "error",
reason: "pubkey-mismatch",
});
});
test("case-insensitive pubkey match", () => {
assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A.toUpperCase()), A), {
action: "prompt",
});
});
+103
View File
@@ -0,0 +1,103 @@
// Subscribe/unsubscribe intermittently showed "Failed to update your
// subscriptions" but worked after a refresh — because the kind-30000 list event
// was SENT and persisted, but no relay ACKed it within the 10s window, so
// assertAnyRelayAccepted threw and the optimistic UI was reverted with a scary
// error. Fix: an all-timeout (unconfirmed) publish of the idempotent replaceable
// list is treated as a SOFT SUCCESS (reconciles on next load); only an explicit
// relay rejection (or a pre-publish signer/encryption error) fails loudly. This
// covers the classifier that makes that call.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-subscription-publish-timeout
// given: "a RelayPublishError from publishing the subscription list"
// when: "its per-relay failures are classified"
// then: "all-timeout => soft success; any explicit rejection => hard failure"
// observable_outcomes:
// - "every-relay-timeout classifies as all-timeouts (soft success)"
// - "an explicit rejection (e.g. 'blocked'/'auth-required') does NOT"
// - "no failures / empty does NOT (nothing to soft-succeed)"
// determinism_controls:
// - "constructs real RelayPublishError objects; no network/clock"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
RelayPublishError,
relayPublishFailuresAreAllTimeouts as subscriptionPublishFailuresAreAllTimeouts,
assertAnyRelayAcceptedOrUnconfirmed,
} from "../js/nostrPublish.js";
// Build a RelayPublishError the same way assertAnyRelayAccepted does — from the
// per-relay result objects (success:false + an Error whose message is the reason).
const errFrom = (results) =>
new RelayPublishError("Failed to publish subscription list to any relay.", results, {
context: "subscription list",
});
const timeout = (url) => ({ url, success: false, error: new Error("publish timeout") });
const rejected = (url, reason) => ({ url, success: false, error: new Error(reason) });
test("every relay timing out is classified as all-timeouts (soft success)", () => {
const e = errFrom([timeout("wss://a"), timeout("wss://b"), timeout("wss://c")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), true);
});
test("an explicit relay rejection is NOT all-timeouts (hard failure)", () => {
const e = errFrom([timeout("wss://a"), rejected("wss://b", "blocked: spam")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), false);
});
test("only-rejections are NOT all-timeouts", () => {
const e = errFrom([rejected("wss://a", "auth-required"), rejected("wss://b", "restricted")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), false);
});
test("no failures / empty does not soft-succeed", () => {
assert.equal(subscriptionPublishFailuresAreAllTimeouts(errFrom([])), false);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(undefined), false);
assert.equal(subscriptionPublishFailuresAreAllTimeouts({}), false);
});
test("case-insensitive: 'Publish Timeout' still counts as a timeout", () => {
const e = errFrom([rejected("wss://a", "Publish Timeout")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), true);
});
// assertAnyRelayAcceptedOrUnconfirmed is the shared helper every list-publish
// caller now uses (subscriptions/blocks/relay-list/dm-hints/hashtags).
const okResult = (url) => ({ url, success: true, error: null });
test("shared helper: a confirmed ack returns unconfirmed:false with accepted", () => {
const summary = assertAnyRelayAcceptedOrUnconfirmed(
[okResult("wss://a"), timeout("wss://b")],
{ context: "list" },
);
assert.equal(summary.unconfirmed, false);
assert.equal(summary.accepted.length, 1);
});
test("shared helper: all-timeout returns unconfirmed:true and does NOT throw", () => {
const summary = assertAnyRelayAcceptedOrUnconfirmed(
[timeout("wss://a"), timeout("wss://b")],
{ context: "list" },
);
assert.equal(summary.unconfirmed, true);
assert.equal(summary.accepted.length, 0);
assert.equal(summary.failed.length, 2);
});
test("shared helper: an explicit rejection still throws RelayPublishError", () => {
assert.throws(
() =>
assertAnyRelayAcceptedOrUnconfirmed(
[timeout("wss://a"), rejected("wss://b", "blocked: spam")],
{ context: "list" },
),
RelayPublishError,
);
});
+111
View File
@@ -0,0 +1,111 @@
// The wallet + storage profile sections now share one sync UI implementation
// (syncSection.js) so they give identical notifications and one overwrite confirm,
// with conflict-only prompting. This covers the shared enable/disable/restore
// handlers (the run* helpers) that both controllers delegate to.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-sync-section-shared-handlers
// given: "a mock sync service + status/success callbacks"
// when: "runSyncToggle / runSyncRestore run for various outcomes"
// then: "consistent messages + conflict-only confirm + rollback on failure"
// observable_outcomes:
// - "enable success -> success status + '<Item> synced (encrypted).' toast"
// - "conflict -> keep-newer status, no success toast, no rollback"
// - "nothing-to-sync -> toggle rolled back + disable() + empty hint"
// - "preEnableConfirm rejected -> toggle rolled back, enable() never called"
// - "restore imported -> success + onImported() re-render"
// determinism_controls:
// - "fully mocked service + callbacks; no DOM/network/clock"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { runSyncToggle, runSyncRestore } from "../js/ui/profileModal/syncSection.js";
function harness(overrides = {}) {
const calls = { status: [], success: [], toggle: [], enable: 0, disable: 0, pull: 0, imported: 0 };
const service = {
enable: async () => overrides.enableResult ?? { ok: true, accepted: 2, total: 3 },
disable: async () => { calls.disable += 1; },
pull: async () => { calls.pull += 1; return overrides.pullResult ?? { found: false }; },
};
const base = {
service,
pubkey: "f".repeat(64),
itemLabel: "wallet connection",
setStatus: (msg, tone) => calls.status.push([msg, tone]),
showSuccess: (msg) => calls.success.push(msg),
setToggle: (v) => calls.toggle.push(v),
onImported: async () => { calls.imported += 1; },
};
const trackedEnable = service.enable;
service.enable = async (...a) => { calls.enable += 1; return trackedEnable(...a); };
return { calls, base };
}
test("enable success: success status + '<Item> synced (encrypted).' toast", async () => {
const { calls, base } = harness();
await runSyncToggle({ ...base, enabled: true });
assert.ok(calls.status.some(([m, t]) => t === "success" && /Synced to 2\/3 relays/.test(m)));
assert.deepEqual(calls.success, ["Wallet connection synced (encrypted)."]);
});
test("conflict: keep-newer status, no success toast, no rollback", async () => {
const { calls, base } = harness({ enableResult: { ok: false, conflict: true } });
await runSyncToggle({ ...base, enabled: true });
assert.ok(calls.status.some(([m]) => /Kept the newer copy/.test(m)));
assert.deepEqual(calls.success, []);
assert.deepEqual(calls.toggle, []); // not rolled back
assert.equal(calls.disable, 0);
});
test("nothing-to-sync: rolls back toggle, disables, shows the empty hint", async () => {
const { calls, base } = harness({ enableResult: { ok: false, error: "nothing-to-sync" } });
await runSyncToggle({ ...base, enabled: true, emptyHint: "Connect a wallet first." });
assert.deepEqual(calls.toggle, [false]);
assert.equal(calls.disable, 1);
assert.ok(calls.status.some(([m, t]) => t === "error" && m === "Connect a wallet first."));
});
test("preEnableConfirm rejected: rolls back toggle, never calls enable()", async () => {
const { calls, base } = harness();
await runSyncToggle({ ...base, enabled: true, preEnableConfirm: async () => false });
assert.deepEqual(calls.toggle, [false]);
assert.equal(calls.enable, 0);
});
test("disable: clears + 'sync turned off' toast", async () => {
const { calls, base } = harness();
await runSyncToggle({ ...base, enabled: false });
assert.equal(calls.disable, 1);
assert.deepEqual(calls.success, ["Wallet connection sync turned off."]);
});
test("restore imported: success status + toast + onImported re-render", async () => {
const { calls, base } = harness({ pullResult: { found: true, imported: true } });
await runSyncRestore({ ...base });
assert.ok(calls.status.some(([m, t]) => t === "success" && /Restored/.test(m)));
assert.deepEqual(calls.success, ["Wallet connection restored."]);
assert.equal(calls.imported, 1);
});
test("restore not found: 'No synced settings found' status", async () => {
const { calls, base } = harness({ pullResult: { found: false } });
await runSyncRestore({ ...base });
assert.ok(calls.status.some(([m]) => /No synced settings found/.test(m)));
assert.equal(calls.imported, 0);
});
test("storage vs wallet produce the SAME message shapes (just the label differs)", async () => {
const w = harness();
await runSyncToggle({ ...w.base, itemLabel: "wallet connection", enabled: true });
const s = harness();
await runSyncToggle({ ...s.base, itemLabel: "storage settings", enabled: true });
assert.deepEqual(w.calls.success, ["Wallet connection synced (encrypted)."]);
assert.deepEqual(s.calls.success, ["Storage settings synced (encrypted)."]);
});
+135
View File
@@ -0,0 +1,135 @@
// Opt-in "keep unlocked" key cache (TODO #51). Default tier = session (survives
// refresh, cleared on tab close); opt-in persistent tier = localStorage (survives
// until site data is cleared). Reads promote a persistent hit into the session tier.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-unlocked-key-cache
// given: "in-memory session + local storage mocks"
// when: "remember/read/forget/clear run for session vs persistent tiers"
// then: "session-only never touches disk; persistent survives; forget/clear wipe both"
// observable_outcomes:
// - "session remember -> readable, NOT in the persistent store"
// - "persistent remember -> readable AND in the persistent store"
// - "toggling persist:false removes the on-disk copy"
// - "a persistent-only key is promoted back into the session store on read"
// - "invalid (non-hex) keys are rejected"
// - "forget clears one pubkey; clearAll wipes everything"
// determinism_controls:
// - "injected in-memory Storage mocks; no real browser storage"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "leaking real storage between tests"]
// relaxation:
// did_relax_any_assertion: false
import test, { beforeEach, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import {
rememberUnlockedKey,
readUnlockedKey,
hasPersistentUnlockedKey,
forgetUnlockedKey,
clearAllUnlockedKeys,
UNLOCKED_KEY_STORE_KEY,
} from "../js/nostr/unlockedKeyCache.js";
function makeStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
_map: m,
};
}
const PUB = "a".repeat(64);
const OTHER = "b".repeat(64);
const KEY = "1".repeat(64);
const KEY2 = "2".repeat(64);
let savedSession;
let savedLocal;
beforeEach(() => {
savedSession = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
savedLocal = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
globalThis.sessionStorage = makeStorage();
globalThis.localStorage = makeStorage();
});
afterEach(() => {
if (savedSession) Object.defineProperty(globalThis, "sessionStorage", savedSession);
else delete globalThis.sessionStorage;
if (savedLocal) Object.defineProperty(globalThis, "localStorage", savedLocal);
else delete globalThis.localStorage;
});
test("session-only remember is readable but never written to disk (localStorage)", () => {
assert.equal(rememberUnlockedKey(PUB, KEY), true);
assert.equal(readUnlockedKey(PUB), KEY);
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
});
test("persistent remember is readable AND written to the persistent store", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(readUnlockedKey(PUB), KEY);
assert.equal(hasPersistentUnlockedKey(PUB), true);
assert.ok(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY));
});
test("re-remembering with persist:false removes the on-disk copy but keeps the session copy", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(hasPersistentUnlockedKey(PUB), true);
rememberUnlockedKey(PUB, KEY, { persist: false });
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(readUnlockedKey(PUB), KEY, "still unlocked for the session");
});
test("a persistent-only key (session cleared, e.g. new tab) is promoted back into the session store on read", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
// Simulate a fresh tab: session store empty, persistent store retained.
globalThis.sessionStorage = makeStorage();
assert.equal(readUnlockedKey(PUB), KEY, "restored from the persistent tier");
// And it was warmed into the session tier.
assert.ok(globalThis.sessionStorage.getItem(UNLOCKED_KEY_STORE_KEY));
});
test("invalid (non-hex) private keys are rejected", () => {
assert.equal(rememberUnlockedKey(PUB, "not-hex"), false);
assert.equal(rememberUnlockedKey(PUB, ""), false);
assert.equal(rememberUnlockedKey("", KEY), false);
assert.equal(readUnlockedKey(PUB), "");
});
test("forget clears one pubkey from both tiers, leaving others intact", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
rememberUnlockedKey(OTHER, KEY2, { persist: true });
forgetUnlockedKey(PUB);
assert.equal(readUnlockedKey(PUB), "");
assert.equal(hasPersistentUnlockedKey(PUB), false);
assert.equal(readUnlockedKey(OTHER), KEY2, "other account still unlocked");
});
test("clearAll wipes every cached key from both tiers", () => {
rememberUnlockedKey(PUB, KEY, { persist: true });
rememberUnlockedKey(OTHER, KEY2);
clearAllUnlockedKeys();
assert.equal(readUnlockedKey(PUB), "");
assert.equal(readUnlockedKey(OTHER), "");
assert.equal(globalThis.localStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
assert.equal(globalThis.sessionStorage.getItem(UNLOCKED_KEY_STORE_KEY), null);
});
test("missing storage backends degrade gracefully (no throw)", () => {
delete globalThis.sessionStorage;
delete globalThis.localStorage;
assert.doesNotThrow(() => {
rememberUnlockedKey(PUB, KEY, { persist: true });
assert.equal(readUnlockedKey(PUB), "");
forgetUnlockedKey(PUB);
clearAllUnlockedKeys();
});
});
+372 -2
View File
@@ -504,6 +504,167 @@ toward freshness and looked identical. Gave each a structural identity:
- [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter).
- [ ] Run `npm run test:visual` after layout changes; update baselines deliberately.
### 41. Native browser dialogs → site notification system
- [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed)
with the app's notification system so dialogs are styled + consistent.
- `alert()` (13) → toasts: `this.showError` where the component already had it
(UploadModal), else `notifyError`/`notifySuccess` via a new `js/ui/appNotify.js`
(routes to the app's NotificationController).
- `confirm()` (~13) → a new promise-based **`showConfirm()`** dialog
(`js/ui/confirmDialog.js`) — a `bv-modal modal-always-on-top` overlay returning
`Promise<boolean>` (Confirm/Cancel/backdrop/Escape, danger variant focuses
Cancel). Importable anywhere, so future confirmations use it too. Stacks above
other modals and is typable thanks to the stacked-modal focus fix.
- Migrated: UploadModal, MyVideosController, ProfileStorage/Wallet/Relay
controllers, profileModalController, subscriptionHistoryController,
settingsRestorePrompt (default `confirm` now `showConfirm`), nostr/client
(deleteAllVersions). `confirmSyncOverwrite` helpers made async (the sync layer
already `await`s `confirmOverwrite`). Tests: `tests/confirm-dialog.test.mjs` (6).
Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT
parse JS — verify migrations by importing the module / running its test.
### 42. Admin whitelist edit fails with nsec / NIP-46 (only NIP-07 worked) — FIXED
- [x] **DONE 2026-06-30.** Editing the admin whitelist/blacklist worked with a NIP-07
extension but silently failed with nsec and NIP-46 (Amber). Root cause:
`adminListStore.persistNostrState` signed with `window.nostr.signEvent` directly
(and required `requestDefaultExtensionPermissions`), so non-extension logins threw
"nostr-extension-missing". Fix: sign with the **active signer**
(`getActiveSigner()` from the registry — works for extension/nsec/NIP-46, all expose
`signEvent`), and only run the extension-permission prompt when the signer is an
extension (`type === "extension"/"nip07"`). Regression test added to
`tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent`
throws). Build + lint clean.
- [x] **AUDIT DONE 2026-06-30 — clean (adminListStore was the only bug).** Swept every
`.signEvent(`, `window.nostr`, and `ensureExtensionPermissions`/
`requestDefaultExtensionPermissions` caller:
- **Signing:** NO `window.nostr` is used to sign/encrypt anywhere. Every publish
path signs with the active signer (`signer.signEvent`): subscriptions, userBlocks,
hashtag prefs, relay prefs, moderationService reports, nip71MirrorService,
encryptedSyncFacade, DM seal + legacy DM (`nostr/client` signingAdapter defaults
to the active signer), DM relay list, admin lists.
- **Extension-permission gates:** every caller (reactions, comments, subscriptions,
userBlocks, watchHistory, relayManager, DM relays) guards the request behind a
signer-type (`extension`/`nip07`) or capability check, so nsec/NIP-46 skip it and
go straight to active-signer signing — they never hit the `extension-missing`
failure that broke admin lists.
- **Decrypt:** gates on the active signer's nip04/nip44 capability with `window.nostr`
only as a fallback; nsec (closures) + NIP-46 (remote) both decrypt fine.
- **Cleanup note (low priority):** `js/auth/signingAdapter.js`
(`createNip07SigningAdapter`, defaults to `window.nostr`) is dead production code —
referenced only by its own test, no app callers. Harmless; remove or repoint at
the active signer if ever reused.
### 43. Logged-in profile card at the top of the profile modal (UX)
- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
profile modal, styled as a "profile card" that sits **half-on / half-off** the
modal edge (overlapping the top border) for a polished look. Goal: users can see
WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
from the profile cache for the active pubkey; update on profile switch.
### 44. In-modal storage selector for uploads (UX)
- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
**without leaving the upload modal** — today they must close it, open Profile →
Storage, change the default/selected connection, then reopen upload. Add a provider
picker (driven by `storageService.listConnections`) + inline unlock in the upload
modal's storage section; selecting one sets it as the active upload target for this
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
connection model.
### 45. Upload modal: suggest tags from the user's previous videos (UX)
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap
chips of the user's most-used past hashtags ("Reuse a tag from your videos:").
Source: `app.getUserHashtagSuggestions()` reads the user's own videos
(`nostrService.getActiveVideosByAuthors([pubkey])`) and ranks their hashtags via
`js/utils/hashtagSuggestions.js` `rankHashtagsByFrequency` — count = number of
videos using each tag, normalized (case + `#`) and deduped per video, ties broken
alphabetically (top 12). Tags come from `video.nip71.hashtags` (falls back to raw
`t` tags minus bitvid's fixed `t=video` marker). Clicking a chip adds it to the
NIP-71 `t` repeater (skips duplicates); already-added chips dim + disable. Hidden
for users with no past tags. Rendered on modal open. Tests:
`tests/hashtag-suggestions.test.mjs` (5, pure ranking). Lint + build green.
### 46. Feed auto-refreshes after posting a video (UX)
- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the
just-published legacy event into the shared client cache via a new
`nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the
new video shows in the feed **instantly**, before the relays echo it back; and (2)
refreshes via the canonical `refreshAllVideoGrids({ reason: "video-published",
forceMainReload: true })` instead of a bare `loadVideos()` — so it refreshes
**whichever feed is active** (For You / Explore / Recent) plus the subscription and
channel grids, and forces a render (bypassing the load cooldown) so the injected
video is actually shown. `ingestLocalVideoEvent` mirrors live ingestion (convert →
root created_at → tombstone guard → allEvents/activeMap), so it obeys the same
dedupe/active-key rules (#20/#21): a later relay copy of the same event (same
`created_at`) won't duplicate it, and an older revision can't overwrite a newer
active entry. Tests: `tests/optimistic-feed-insert.test.mjs` (6 — surfaced instantly,
no-duplicate on re-ingest, newer-wins, older-doesn't-clobber, invalid→null, garbage
handled).
### 47. "Most Zapped" sidebar tab (trending-by-zaps)
- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by
**total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735
receipts) already used for the zap system; build a feed source/ranker that sorts by
zap total over a window, wired into the feed engine like Trending. Depends on
reliable zap data (couples with the zap system already shipped in #3).
### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07)
- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail
silently ("secret-required") — `switchProfile` called the nsec provider with no
passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec
target, checks the stored key (`getStoredSessionActorMetadata` +
`evaluateStoredNsecSwitch` — must be a persisted nsec for THIS account), prompts for
the PIN/passphrase via a new `showPasswordPrompt` dialog
(`js/ui/promptDialog.js`), and calls `switchProfile(pubkey, { unlockStored: true,
passphrase })`. `authService.switchProfile` now forwards `unlockStored`/`passphrase`
→ `requestLogin` → the nsec provider's `unlockStoredSessionActor` restores the signer
and activates the account. Wrong PIN → "Incorrect PIN/passphrase" toast; no stored
key / different account → a clear "log in with its nsec" toast. So you can switch
NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) +
`tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25.
- [x] **NIP-46 switching FIXED 2026-06-30.** Confirmed the same bug: switching to a saved
NIP-46 account called the provider with no `reuseStored`, so it started a fresh
handshake (needing a new connect URI/QR) instead of reconnecting. Now
`handleProfileSwitchRequest` sends `{ providerId: "nip46", reuseStored: true }` for
NIP-46 targets → `switchProfile` forwards `reuseStored` → the nip46 provider's
`useStoredRemoteSigner` reconnects the stored session and activates the account. On
failure → "Couldn't reconnect to this account's remote signer" toast.
- [x] **Wrong-account guard (all methods) 2026-06-30.** `requestLogin` now enforces
`expectPubkey` (only set by `switchProfile`): if a single-slot stored session
resolves to a DIFFERENT account than requested, it rejects with `pubkey-mismatch`
instead of silently switching to the wrong profile. Also protects NIP-07 (switching
to an account the extension isn't currently on now fails clearly instead of grabbing
the extension's active account). Tests in `tests/authService.test.mjs` (option
forwarding + expectPubkey rejection).
- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now
per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted,
encryption, createdAt } }`), following the existing `bitvid:<thing>:<npub>`
convention (profileCache, nwcSettings, …). Several saved nsec accounts each keep
their own key on the device, so the switcher can move between them. The legacy v1
single slot is kept in sync as the "last-saved default" (boot restore / no-arg
reads) and is migrated into the map on first read. `readStoredSessionActorEntry`,
`getStoredSessionActorMetadata`, and `unlockStoredSessionActor` all take an optional
target pubkey; the nsec provider threads `expectPubkey` through so a switch unlocks
the requested account (not the last-saved one), with the `expectPubkey` guard as a
backstop. IndexedDB mirror is now keyed per-pubkey too. Added
`listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs`
(7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe,
v1→v2 migration, missing-account returns null).
- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is
now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`),
mirroring the nsec store. Several saved NIP-46 accounts each keep their own
reconnectable session, so the switcher can move between them. The legacy v1 single
slot is kept in sync as the last-connected default (boot restore) and migrated into
the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an
optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by
`userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one
account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey
})` selects the target account's session and, on access-denial, forgets only that
account; the nsec/nip46 providers thread `expectPubkey` through so a switch
reconnects the requested account (with the `expectPubkey` guard as a backstop). Added
`listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path
is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7).
### 8. Orphan storage garbage-collection tool
- [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no
live note references and offers per-file delete (under the user's prefix only).
@@ -613,8 +774,16 @@ toward freshness and looked identical. Gave each a structural identity:
`tests/admin-event-blacklist.test.mjs` (9 — record helpers + editor-gated action,
fallbacks, refusal, publish-failure). Build + lint clean; admin/access/moderation
suites 56/56.
- [ ] **Follow-up:** surface the per-event list (view/unblock) in the Admin pane /
future #23 admin tab; today removal is via `removeFromEventBlacklist` (no UI yet).
- [x] **Follow-up DONE 2026-07-01.** The admin pane is now organized into a sub-tab
toolbar (Whitelist / Blacklist / Blocked videos / Moderators — Moderators tab
super-admin-only) and a **Blocked videos** tool surfaces the per-event list:
view every blocked video (title+author when cached, else shortened id), **unblock**
per row (confirm → `removeFromEventBlacklist`), and **add by nevent/hex id**. Reuses
the existing accessControl event-blacklist API + `onEventBlacklistChange` (live
re-render) and refreshes the grids on change; no backend changes. Row logic in
`js/ui/profileModal/blockedVideosSection.js`; wiring in `ProfileAdminController`.
Tests: `tests/profile-blocked-videos.test.mjs` (7) + updated profile-modal-controller
admin test for the sub-tab layout.
### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24
- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time
@@ -1174,6 +1343,7 @@ Threat model / cautions (write these into the feature + docs):
> config flags from the start.
### 16. Nostr live streams — INGEST / watch-only (zap.stream, shosho.live)
> **Dev plan: `docs/live-ingest-plan.md`** (decisions open + Phase 0 research spike).
> Ingesting live streams (watch others' streams) and PUBLISHING a stream
> ("go live", #16c) are TWO separate functions and must each have their OWN config
> flag — the maintainer wants to enable/disable ingest and publish independently.
@@ -1189,6 +1359,9 @@ Threat model / cautions (write these into the feature + docs):
populate `streaming`/`recording`, and HLS playback support in the player.
### 16c. Publish live streams — "Go Live" from bitvid (like zap.stream)
> **Dev plan: `docs/live-publish-plan.md`** (FUTURE — after #16 + #16b; static
> client + optional Media Node/Bridge split; MediaMTX; NIP-98 bridge auth;
> archive→VOD reuses the s-tag/info.json model. Decisions open.)
- [ ] Let users **broadcast their own stream** (camera / desktop / arbitrary source)
to Nostr via bitvid, the way zap.stream does: publish a NIP-53 kind-30311 live
event (status live → ended), push the media to a streaming endpoint (HLS), and
@@ -1203,6 +1376,7 @@ Threat model / cautions (write these into the feature + docs):
streaming-server dependency early (relates to the CORS/edge constraints).
### 16b. Nostr short-form video notes — watch-only (new sidebar tab)
> **Dev plan: `docs/shorts-plan.md`** (decisions open; ingest already exists).
- [ ] Short-form (vertical/portrait) video is **NIP-71 kind 22** (the short-form
counterpart to kind 21 normal video) — distinct from live streams. bitvid
already ingests NIP-71 (see #17), so much of the parsing exists; this is about
@@ -1355,3 +1529,199 @@ that used it.
switching, and the video-card tap-active state — plus the profile-nav app-grid.
- [ ] **VERIFY live on a phone**: modals should now be full-screen; tapping a video
card shows the active lift/accent/marquee; profile modal tabs are the app-grid.
### 49. Relay-ack false-negatives on the generic content-publish path (HIGH-STAKES — deferred)
> No rush — only act if it starts causing problems. Same bug class as the list-publish
> fix (commit 47f2a9a8): when no relay ACKs within the 10s `RELAY_PUBLISH_TIMEOUT_MS`
> window, `assertAnyRelayAccepted` throws even though the event was sent and almost
> always persisted (works after refresh). Already fixed for the replaceable LISTS
> (subscriptions/blocks/relay-list/dm-hints/hashtags) via
> `assertAnyRelayAcceptedOrUnconfirmed`.
- [ ] **Generic content publish still hard-errors on all-timeout.** The
`signAndPublishEventHelper` (`js/nostr/publishHelpers.js:472`) + `rebroadcast`
(`:1334`) + `video revert` (`js/nostr/client.js:3064`) still use bare
`assertAnyRelayAccepted`, so a slow-ack **video publish/edit/delete/revert**
can show "Failed to share video" (or similar) when it actually published.
HIGH-STAKES because these are the flagship content flows.
- [ ] **Why it wasn't bundled in:** video publish feeds a relay summary into
`describePublishOutcome` (the "shared to N relays" / warning UI). Applying
`assertAnyRelayAcceptedOrUnconfirmed` here must keep that outcome honest (an
unconfirmed publish should read as "shared, couldn't confirm" — NOT a hard
failure and NOT a false "shared to N relays"). Needs its own careful pass +
tests around the video outcome-describer and delete semantics (a false
"deleted" is worse than a false "publish failed").
- [ ] Deletions (kind 5) especially: decide whether an unconfirmed delete should
report success optimistically or stay conservative.
### 50. isDevMode — keep it; fix the test-harness gap (LOW priority)
- Decision (2026-07): **KEEP `isDevMode`.** It's used in ~26 files, chiefly to gate
**runtime schema validation** in `js/nostrEventSchemas.js` (event builders validate
against their schema only in dev — catches malformed events without the prod cost),
plus dev-only verbose logging and dev-only feature flags (`FEATURE_SEARCH_FILTERS`).
Removing it would drop that safety net or force validation into prod. Not a candidate
for removal.
- [ ] **Test-harness gap (not an isDevMode bug):** `tests/nostr-publish-rejection.test.mjs`
asserts `isDevMode === true` but never sets `globalThis.__BITVID_DEV_MODE_OVERRIDE__`
before importing `js/config.js`, so it fails in a non-dev env (`isDevMode` falls back
to `IS_DEV_MODE` = false). Fix: force dev mode via the override in that test's setup.
Fails identically on committed HEAD — pre-existing, low priority.
- [ ] (Optional) sweep other tests that assume dev mode without forcing the override, for
consistency.
## Open — rough edges found in hands-on testing (2026-07-01)
Reported by the maintainer after live testing on `unstable`. Several share a root
cause with **#36** (a persisted **nsec** session restores the pubkey + UI but NOT the
in-memory signer after a page reload, because the private key is only
passphrase-encrypted). Items 51, 56, 57 are all facets of that.
### 51. Shouldn't have to re-"unlock" storage after every refresh (UX / session)
- [ ] **Symptom:** after a page refresh the user must unlock storage again every time.
- [ ] **Root cause:** same as **#36** — the nsec signer + unlocked storage master key
live only in memory, so a reload drops them. Today the fix (#36) makes the error
*actionable* (re-enter passphrase), but the user still has to do it every reload.
- [x] **DONE 2026-07-01 — opt-in "keep unlocked" cache.** Decision (maintainer):
**opt-in** tiers. On a successful nsec unlock the decrypted key is cached in a
new `js/nostr/unlockedKeyCache.js`:
- **session tier (default, always):** `sessionStorage` — survives refresh /
navigation, auto-cleared on tab close. Roughly the same exposure as the
in-memory signer, so no more PIN on every reload within a session.
- **persistent tier (opt-in checkbox):** the unlock prompt now shows a "Keep
me unlocked on this device" checkbox (with an inline unencrypted-at-rest
warning); when checked the key is ALSO written to `localStorage` and survives
until the user clears site data.
`client.restoreUnlockedSigner(pubkey)` re-registers the signer from the cache
WITHOUT a passphrase (verifies the key derives to that pubkey; a mismatch
forgets it). It's called proactively from `ensureActiveSignerForPubkey` (the
auto-login/refresh path) and lazily from `ensureEncryptionCapableSigner` before
any prompt — so after a reload the signer is restored silently and every
signing/encryption flow (storage, hashtags, DMs, reactions, blocks, subs) just
works. Logout / remove-saved-profile forget the cached key
(`forgetUnlockedSigner`). Tests: `tests/unlocked-key-cache.test.mjs` (8). Lint +
build green.
- [x] **Storage + NWC inherit the kept-unlocked signer — DONE 2026-07-01.** Storage
and NWC don't have their own passphrase: the storage master key and the NWC
settings are ENCRYPTED WITH THE SIGNER (nip44/nip04 self-encryption) and unlocked
by it. So rather than caching a second secret, both now inherit the kept-unlocked
nsec signer. `_initAutoLogin` calls `nostrClient.restoreUnlockedSigner(pubkey)`
BEFORE the boot re-login, so the login flow (NWC hydrate, list decrypt) and the
existing storage auto-unlock (ProfileStorageController / UploadModal call
`storageService.unlock` when a signer is present) all succeed silently on refresh
— no passphrase, no manual "Unlock storage". Tied to the signer cache: persists
exactly as long as the kept-unlocked key does (session or until site-data clear).
(NIP-07/46 profiles no-op — nothing is cached; their extension/remote signer
already persists.)
- [ ] **VERIFY on unstable:** unlock once (box unchecked) → refresh → no PIN, and
Storage shows Unlocked + the wallet/NWC connection is live without re-entry;
close the tab + reopen → PIN again. Check the box → close/reopen the browser →
still no PIN and storage/NWC still ready; Log out or clear site data → PIN again.
- [x] **"Lock this device" control — DONE 2026-07-01.** A footer button in the profile
modal (desktop + mobile), shown only when the active account has a cached
"keep unlocked" key to forget (`app.isSessionKeptUnlocked`). Clicking it runs
`app.lockKeptUnlockedSession(pubkey)`, which re-locks everything the signer
unlocks in one action: forgets the cached key (both tiers → future reloads
re-prompt), drops the in-memory signer (`logoutSigner` → the current session
re-locks now), `storageService.lock(pubkey)`, and `nwcSettingsService.clearCache()`.
Visibility refreshes on `renderSavedProfiles` + `selectPane`. Tests:
2 in `tests/profile-modal-controller.test.mjs` (visibility gate + click locks the
active pubkey). Lint + build green.
- [ ] **Follow-up (optional):** surface whether the current session is *persistently*
remembered (disk vs session tier) in the UI, and consider a "keep unlocked" toggle
in the storage/wallet panes for users who set it up there rather than via a prompt.
### 52. After editing a video, the video grids don't refresh to show the update (BUG)
- [ ] Editing a video leaves the grids showing the pre-edit version until a manual
reload. Publishing a NEW video already refreshes the feed (**#46**); the **edit**
path needs the same post-publish grid refresh (title/thumbnail/description/url).
Likely wire the edit-submit success into the same `refreshAllVideoGrids` /
`onVideosShouldRefresh` path #46 uses, keyed by `videoRootId`.
### 53. Channel-profile link in the video player modal doesn't work (BUG)
- [ ] Clicking the channel/creator link inside the video player modal does nothing
(should navigate to that author's channel profile view). Audit the modal's
channel-link handler (`js/ui/videoModal*` / the author byline) — likely a missing
or wrong nav callback (compare with the working channel links in the grid cards).
### 54. Like / dislike buttons in the video player modal don't work (BUG)
- [ ] The reaction (like/dislike) buttons in the player modal are unresponsive / don't
persist. Audit `reactionController` wiring into the video modal (event binding,
active-signer availability, publish path). Note: reactions require signing — verify
this isn't another "lost signer after refresh" (#57) surfacing as a dead button; if
so, prompt to unlock rather than silently no-op.
- [x] **Lost-signer variant addressed 2026-07-01.** `ReactionController.handleReaction`
now runs the `ensureEncryptionCapableSigner` gate (`need:"sign"`) before
publishing, so a reloaded nsec session re-unlocks with one passphrase prompt
instead of the reaction silently failing (rolled-back button + generic error).
Tests: `tests/reaction-signer-gate.test.mjs`. **Still VERIFY on unstable** whether
any residual dead-button behavior remains (event binding / pointer availability),
which would be a separate wiring bug from the signer issue.
### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX)
- [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay
timeout + parity-seed fix, the channel wall streams in slowly. Cache that channel's
events so revisits render instantly.
- [ ] **Generalize to every grid:** cache the events behind each video grid (feed tabs +
channel walls) so they render from cache immediately, then refresh in the
background and **merge in only new events** (don't blank + re-fetch). Fresh content
still matters — proposed balance: **cache-and-refresh simultaneously**, appending
new events as they arrive, with an explicit **pull-to-refresh / refresh button**
for a forced re-fetch. Decide cache store (in-memory vs IndexedDB via
`js/state/cache.js`) + a staleness/TTL policy.
- [ ] **Cache media too:** thumbnails and profile images/banners should be cached (browser
cache headers / a warm cache / persistent store) so they don't reload from scratch
on every visit. Cross-ref `js/channelProfile.js` and the card renderers.
### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX)
- [ ] When storage is locked, the **upload** and **edit** modals should open the
passphrase/PIN unlock popup instead of surfacing a raw error — for **both** the
video-file upload and the **thumbnail** upload. **#36** already added this
auto-open-login unlock for the **upload** modal; extend the same treatment to the
**edit** modal and the **thumbnail** upload path.
- [ ] Combine with **51**: once unlocked, cache the state so the user isn't re-prompted
on every upload/refresh. "Both would be nice" — actionable prompt AND cached unlock.
### 57. nsec / NIP-46 accounts: encryption ops fail after refresh — re-prompt, don't blanket-error (BUG)
- [ ] **Symptom:** adding/editing a hashtag on an **nsec**-logged-in account errors with
*"Connect a Nostr signer that supports encryption before managing hashtag
preferences."* Same class blocks other NIP-44/encryption list ops (subscriptions,
blocks, DMs) after a reload.
- [ ] **Root cause (likely #36):** the reload dropped the in-memory nsec signer, so
`nip44Encrypt`/`nip44Decrypt` aren't available even though the user "looks" logged
in. The guard then emits a blanket "connect a signer that supports encryption."
- [ ] **Fix intent:** ALL signing/encryption functions must work across ALL signer types
(nsec / NIP-46 / NIP-07). When the op needs a locked nsec key, **prompt to re-enter
the PIN/passphrase to re-unlock the signer** (reuse #36's unlock flow) instead of a
dead-end error. Cross-ref **#48** (fluent switching) and the account-switch refresh
work (commit `87af650b`). Audit every "signer that supports encryption" guard and
route it to the unlock prompt when a saved nsec key exists for the active pubkey.
- [x] **Shared gate built + hashtags wired — DONE 2026-07-01.** New reusable
`app.ensureEncryptionCapableSigner({ pubkey, need, promptMessage })`
(`js/app.js`, pure branching in `js/nostr/ensureSignerDecision.js`): checks the
active signer's capabilities; if it can't encrypt/sign AND a **matching** locked
nsec key exists for the account, prompts once for the passphrase and re-unlocks
the signer app-wide via `unlockStoredSessionActor` (one unlock re-enables every
flow until the next reload). Never unlocks a different account's key
(pubkey-mismatch guard). Exposed to the profile modal as
`services.ensureEncryptionCapableSigner`. **Hashtag add/edit** now routes through
it (`ProfileHashtagController.persistHashtagPreferences`): a cancelled prompt
silently aborts, a bad passphrase shows its own toast, and publish never runs
until the signer is confirmed. Tests: `tests/ensure-signer-decision.test.mjs` (9),
+ 2 wiring tests in `tests/profile-modal-controller.test.mjs`. Lint + build green.
- [x] **Fast-follows wired — DONE 2026-07-01.** All remaining signing/encryption
call sites now route through `ensureEncryptionCapableSigner`, short-circuiting
only on the user-driven outcomes (cancel / bad-passphrase) so every other case
still falls through to the flow's own error handling:
- **Reactions (#54):** `ReactionController.handleReaction` gates before the
optimistic update with `need:"sign"` (kind-7 reactions don't need encryption).
Tests: `tests/reaction-signer-gate.test.mjs` (3).
- **Blocks:** `authSessionCoordinator.handleProfileBlocklistMutation`
(`need:"encrypt"`, NIP-04 mute/block lists).
- **Subscriptions:** the channel-profile Subscribe/Unsubscribe toggle
(`js/channelProfile.js`, `need:"encrypt"`, kind-30000).
- **DM send:** both composers — `handleSendProfileMessage` and
`handleDmAppShellSendMessage` (`need:"encrypt"`, NIP-17/NIP-04).
Lint + build + related suites (decision, reaction, DM, profile-modal, switch)
green.
- [ ] **VERIFY on unstable:** with an nsec account, reload, then (a) add a hashtag,
(b) like/dislike a video, (c) block/unblock a creator, (d) subscribe to a channel,
(e) send a DM — each should show ONE passphrase prompt (not a blanket error), then
work; the single unlock should cover the rest until the next reload.