mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
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:
commit
ac1b3d3673
79 files changed
+7515
-557
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/**',
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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" }]), []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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}())`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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, "+");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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)."]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user