diff --git a/docs/plans/2026-04-22-nip-audio-rooms-draft.md b/docs/plans/2026-04-22-nip-audio-rooms-draft.md index 2b9051f6b..70ef43d4c 100644 --- a/docs/plans/2026-04-22-nip-audio-rooms-draft.md +++ b/docs/plans/2026-04-22-nip-audio-rooms-draft.md @@ -2,6 +2,12 @@ `draft` `optional` +> **Revision 2026-04-26.** Replaces the IETF MoQ-transport / `GET /` +> sketch from the original draft. The wire described here matches the +> `nostrnests/nests` + `kixelated/moq` reference deployment running in +> production today, and is what `amethyst/nestsClient` implements. +> See "Reconciliation with previous spec drafts" near the bottom. + ## Abstract This NIP specifies the client/server control plane and real-time audio @@ -10,9 +16,10 @@ or speaker. It closes the gap between NIP-53's room discovery (which defines only *what* a room is) and what an audio-capable client must do to actually hear and speak in it. -A standard, vendor-neutral profile lets multiple server implementations -interoperate with multiple clients. A client that follows this NIP will -work against any server that advertises compliance, and vice versa. +The transport is **moq-lite Lite-03** over WebTransport with a thin +NIP-98-authenticated HTTP `/auth` endpoint that mints an ES256 JWT. A +client that follows this NIP will work against any server speaking the +same wire shape. ## Dependencies @@ -21,9 +28,12 @@ work against any server that advertises compliance, and vice versa. and kind `1311` (Live Activity Chat). - [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md) defines kind `27235` and the `Authorization: Nostr ` header. -- [IETF `moq-transport`](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) - and [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) - provide the audio transport substrate. +- [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) + carries the audio transport. +- **moq-lite Lite-03** (kixelated/moq) — the application-level framing + on top of WebTransport. Selected by ALPN `"moq-lite-03"`. Wire spec: + `kixelated/moq-rs/rs/moq-lite/src/`. NOT to be confused with IETF + `draft-ietf-moq-transport`, which is wire-incompatible. ## Terminology @@ -72,27 +82,62 @@ publish audio via the MoQ session. Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag pointing at the `30312` event. +### Kind 4312 (Admin Command) — moderation + +Ephemeral event used by hosts and admins to issue moderation actions: + +``` +{ "kind": 4312, "content": "", + "tags": [ + ["a", "30312::"], + ["p", ""], + ["action", "kick" | ...] ] } +``` + +Recipients matching the `p` tag MUST verify the signer is a `host` or +`admin` per the room's current `30312` event before acting. The +target self-disconnects on `action=kick`. The host SHOULD also +re-publish the `30312` with the target's `p`-tag removed so any +future joiner sees the updated roster. + +### Kind 10112 (Audio-room server list) + +Replaceable event listing a user's preferred MoQ host servers. Wire +shape mirrors NIP-B7's BlossomServersEvent (kind 10063): + +``` +{ "kind": 10112, + "tags": [ + ["alt", "Audio-room (nests) MoQ servers used by the author"], + ["server", "https://moq.nostrnests.com"], + ["server", "https://moq.example.org"], + ... ], + "content": "" } +``` + +Each `["server", ]` URL is a moq-auth + moq-relay base URL. +Clients SHOULD consume this event when "starting a new space" to +default the `service` / `endpoint` tag fields on the kind-30312 +event they're about to publish. + ## HTTP control plane ### Base URL -The `service` tag from the kind `30312` event is the base URL. All control- -plane requests are constructed as: +The `service` tag from the kind `30312` event is the base URL of the +auth sidecar (a.k.a. `moq-auth`). It exposes exactly two routes: ``` -GET / — room-info / join +POST /auth — mint a JWT for one room+role +GET /.well-known/jwks.json — public keys for JWT verification ``` -where `` is the `d` tag value of the kind `30312` event, -URL-path-encoded. - -The server MAY expose additional paths under the same base (e.g. -`/` for server metadata, `/.well-known/nostr-audio-rooms` -for discovery). This NIP only specifies `/`. +The server SHOULD also expose `GET /health` returning +`{"status":"ok"}` for liveness probes. Anything else SHOULD return 404. ### Authentication -Every request MUST carry a NIP-98 `Authorization` header: +Every `POST /auth` request MUST carry a NIP-98 `Authorization` header: ``` Authorization: Nostr @@ -101,155 +146,191 @@ Authorization: Nostr The kind `27235` event MUST have: - `["u", ""]` -- `["method", "GET"]` (or `POST`, `DELETE`, etc. matching the request verb) +- `["method", "POST"]` +- `["payload", ""]` (NIP-98 §2.2) - `created_at` within 60 s of the server's clock - A valid signature -The server SHOULD reject requests older than 60 s with `401 Unauthorized`. -Servers MAY also reject requests whose signer isn't allowed in the room -(e.g. the room is `private` and the signer isn't on the allow-list). +The server MUST reject requests older than 60 s with `401 Unauthorized`. ### Join / room-info response -`GET /` with a valid NIP-98 header returns a JSON body -with `Content-Type: application/json`: +`POST /auth` body: ```json -{ - "endpoint": "https://relay.example.com:4443/moq", - "token": "eyJhbGciOi…", - "transport": "webtransport", - "codec": "opus", - "sample_rate": 48000, - "frame_duration_ms": 20, - "moq_version": "draft-17" -} +{ "namespace": "nests/30312::", "publish": false } ``` -Fields: +The `namespace` MUST match the regex +`^nests/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$` +(`::`, where `` is `30312` +for now). `publish` is `true` for a host/speaker minting a publish +token, `false` (or omitted) for a listener. -| Field | Type | Required | Meaning | -|---|---|---|---| -| `endpoint` | string (https URL) | yes | WebTransport URL the client connects to. | -| `token` | string | yes | Opaque bearer token the client passes to the WebTransport layer (see below). | -| `transport` | string | no, defaults to `"webtransport"` | Reserved for future transports. Clients MUST fail closed on unknown values. | -| `codec` | string | no, defaults to `"opus"` | Audio codec name. Reserved for future codecs. Clients MUST fail closed on unknown values. | -| `sample_rate` | integer | no, defaults to `48000` | Samples per second. | -| `frame_duration_ms` | integer | no, defaults to `20` | Audio frame duration. | -| `moq_version` | string | no, defaults to server's preferred | Identifier of the MoQ-transport draft the server speaks (e.g. `"draft-17"`). Clients MUST include this version (and nothing else) in CLIENT_SETUP. | +Response on success: -Unknown fields MUST be ignored by the client to allow forward-compatible -server extensions. +```json +{ "token": "eyJhbGciOi…" } +``` -Error responses are standard HTTP status codes with an optional JSON body -`{"error": "", "reason": ""}`: +The `token` is an ES256 JWT signed by the `service` server's keypair; +its public key is available at `/.well-known/jwks.json`. The +relay (`moq-relay`) refreshes the JWKS every 30 s. + +JWT claims: + +| Claim | Meaning | +|---|---| +| `root` | Echoed `namespace` value. The relay matches this against the WT URL path. | +| `get` | `[""]` — listener may subscribe to anything under `root`. | +| `put` | `[]` — publisher may only `ANNOUNCE` under `root/`. Present only when `publish: true`. | +| `iat` / `exp` | Standard. Token lifetime is **600 s**; clients re-mint on expiry. | + +Error responses: | Status | When | |---|---| -| `401` | NIP-98 missing, expired, or signature invalid. | -| `403` | Signer isn't allowed in this room (e.g. room `closed`, signer blocked). | -| `404` | Room `d` tag unknown to this server. | -| `410` | Room has ended. | -| `503` | Server is healthy but audio backend is unavailable. | +| `400` | Body missing / malformed JSON / `namespace` fails the regex. | +| `401` | Authorization missing, signature invalid, `u`/`method`/`payload` mismatch, or `created_at` outside ±60 s. | +| `429` | Rate-limited (≥ 20 mint requests per IP per 60 s). | + +There is **no per-room HTTP info endpoint, no `/permissions`, no +`/recording*`** — the only mutable per-room state lives in Nostr +events (kind 30312 for the room, 10312 for presence, 1311 for chat, +etc.). ## Audio transport ### WebTransport -Clients open a WebTransport session against the `endpoint` URL using the -Extended CONNECT handshake ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) -+ the [WebTransport-HTTP/3 draft](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)). -The HTTP CONNECT request MUST carry: +Clients open a WebTransport session against the `endpoint` URL using +the Extended CONNECT handshake +([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) + +[`webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)): ``` :method: CONNECT :protocol: webtransport :scheme: https :authority: -:path: -Authorization: Bearer +:path: /?jwt= ``` -Servers MUST: +The path component is the **`namespace`** the JWT was minted for — +i.e. exactly the value sent in the `POST /auth` body. The relay +matches it against `claims.root` and rejects any mismatch with HTTP +401 (`IncorrectRoot`). The token is delivered as the **`?jwt=`** query +parameter; the relay does **not** inspect the `Authorization` header. -- advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441)), -- advertise `SETTINGS_ENABLE_WEBTRANSPORT = 1`, -- advertise `SETTINGS_H3_DATAGRAM = 1` ([RFC 9297](https://www.rfc-editor.org/rfc/rfc9297)). +Servers MUST advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`, +`SETTINGS_ENABLE_WEBTRANSPORT = 1`, and `SETTINGS_H3_DATAGRAM = 1`. -### MoQ session +### moq-lite session -After the WebTransport session is open, the client opens its first -bidirectional stream as the MoQ control stream and sends `CLIENT_SETUP` -advertising the `moq_version` from the room-info response. The server replies -with `SERVER_SETUP` selecting that version, or closes the session. +The application-level framing is **moq-lite Lite-03** (kixelated/moq). +The variant is selected by the WebTransport ALPN — clients SHOULD +advertise `"moq-lite-03"` (and MAY include the legacy `"moql"`). -### Track namespace + name +There is **no in-band SETUP message** in Lite-03 — the WebTransport +handshake itself is the handshake. After the WT session opens, both +sides go straight to opening per-purpose streams. -A speaker's audio track is published by the server and subscribed-to by -clients under: +### Per-bidi `ControlType` discriminator + +Every client-initiated bidi opens with a single varint +`ControlType` byte that selects the message family: + +| Code | Name | +|---|---| +| 1 | Announce (subscriber-of-announces ↔ publisher) | +| 2 | Subscribe (subscriber → publisher) | +| 3 | Fetch (decl. only; not used for live audio) | +| 4 | Probe (bitrate hint) | + +Body framing on every bidi/uni stream is `varint(size) + payload bytes`. +Strings are `varint(length) + UTF-8`. Integers are RFC 9000 §16 +varints. + +### Speaker — publisher path + +The relay opens both `Announce` and `Subscribe` bidis **to** the +publisher (publisher accepts inbound bidis). For each: + +- `Announce` body: `AnnouncePlease { prefix: string }`. The publisher + replies `Announce { status: u8 (0=Ended,1=Active), suffix: string, + hops: u62 }` with `status=1`, `suffix=`. Publishers + MUST emit `status=0` on graceful shutdown. +- `Subscribe` body: `Subscribe { id: u62, broadcast: string, + track: string, priority: u8, ordered: u8, maxLatencyMillis: varint, + startGroup: varint, endGroup: varint }`. The publisher replies + `SubscribeOk { priority, ordered, maxLatencyMillis, startGroup, + endGroup }`. + +Per-group audio bytes flow on **client-initiated uni streams** the +publisher opens. Each uni stream has the layout: ``` -track_namespace = [ ] -track_name = (64 lowercase hex chars) +DataType varint = 0 (Group) +GroupHeader (size-prefixed): { subscribe: u62, sequence: u62 } +frames until QUIC FIN: { size: varint, payload: } ``` -The namespace is a **one-element tuple** containing the room's `d` tag. It is -intentionally vendor-neutral: there is no `"nests"` or server-brand prefix. +### Listener — subscriber path -Rationale: the namespace is uniquely keyed by the NIP-53 room id and is -sufficient for a client to subscribe without knowing anything about the -server's brand. Multiple servers hosting rooms with the same `d` tag is -already not a concern — a `d` tag is unique under a host pubkey, and the -room-info response identifies exactly which server's MoQ endpoint the -client must connect to. +A listener opens its own client-initiated bidis to the relay: -### Audio objects +- `Announce` (ControlType=1) with + `AnnouncePlease { prefix: }` to receive + a flow of `Announce` updates from the relay (one per active + publisher). +- `Subscribe` (ControlType=2) per `(broadcast, track)` pair the listener + wants. The relay replies `SubscribeOk` and forwards group uni + streams as the publisher emits them. -Each OBJECT on a speaker's track carries one Opus packet as its payload: +For a nests audio room the listener's wire usage per speaker is: -- Opus encapsulated in **raw packet form** (no Ogg / no TOC-prefix), as - produced by `libopus` / Android `MediaCodec("audio/opus")` encoder output. -- `sample_rate` from the room-info response (default 48 000 Hz). -- `frame_duration_ms` from the room-info response (default 20 ms). -- Mono (single channel), signed 16-bit PCM domain, VBR encoding. +| Field | Value | +|---|---| +| `broadcast` | `` (single string, no `nests/` prefix) | +| `track` | `"audio/data"` | +| Optional metadata track | `"catalog.json"` (JSON description of the broadcast — clients MAY skip and just subscribe to `audio/data`). | -Object delivery MAY use either: +Path normalisation (strip leading/trailing/duplicate `/`) is +**mandatory** on every wire boundary; `"/foo//bar/"` and `"foo/bar"` +MUST round-trip identically. -- **OBJECT_DATAGRAM** (lowest latency, lossy) — recommended for live audio. -- **STREAM_HEADER_SUBGROUP** uni-streams (reliable) — MAY be used by servers - that need delivery guarantees; clients MUST support receiving both. +### Audio frame format -`group_id` on each object is the speaker's monotonic group counter (one -group per speaker session). `object_id` is the zero-based Opus packet index -within the group. `publisher_priority` is `0x80` unless the server has -reason to vary it. +Each frame payload on the `audio/data` track is one **Opus packet** in +raw form (no Ogg, no TOC prefix), as produced by `libopus` / +Android `MediaCodec("audio/opus")` output: -### Server-to-client vs. client-to-server audio +- 48 000 Hz, mono, signed-16-bit PCM domain, 20 ms frame duration, VBR. -A **listener** SUBSCRIBEs to each speaker's track namespace + track name. -The server MUST accept SUBSCRIBEs from any authenticated client (subject to -its access control). +There is **no per-frame envelope** beyond the size varint — no +timestamp, no codec config, no flags. Receivers reconstruct timing +from frame arrival + the group sequence. -A **speaker** ANNOUNCEs `[ ]` and publishes objects on track -name ``. The server MUST verify that the announcing -pubkey is listed in the room's `p` tag set with role `host` or `speaker` -**at the current moment** (the room event is replaceable — the set can -change). On role revocation, the server MUST close the publisher's track. +### Unsubscribe / close -### Leaving +There is **no UNSUBSCRIBE message**: a subscriber FINs the send side +of the subscribe bidi and the relay tears down. A publisher closes by +emitting `Announce { status: 0=Ended }` on every active announce bidi +and FINing the current group's uni stream. -To leave, the client SHOULD: +Errors on any stream are conveyed by `RESET_STREAM` with a u32 error +code; there is no SUBSCRIBE_ERROR / ANNOUNCE_ERROR message. -1. UNSUBSCRIBE every track it had open. -2. If it was a speaker, send UNANNOUNCE + `SubscribeDone` for its own track. -3. Publish a final kind `10312` presence event (optional, improves UX for - other peers) with `["muted","1"]` and without `["hand","1"]`. -4. Close the WebTransport session with capsule type `0x2843` +### Leaving the room (Nostr-side) + +In addition to the wire-level cleanup above, a leaving client SHOULD: + +1. Publish a final kind `10312` presence event with + `["muted","1"]`, `["onstage","0"]`, no `["hand","1"]` to flush + stale UI on other peers. +2. Close the WebTransport session with capsule type `0x2843` (`WT_CLOSE_SESSION`, code `0`). -The server SHOULD treat 30 s without a kind `10312` refresh (per NIP-53) as -"left" for UI purposes. - ## Chat Per NIP-53 kind `1311`. No additions. @@ -291,26 +372,31 @@ A client claiming this NIP MUST: ## Reference implementation -A compliant Kotlin/Android reference client is in development at -[`amethyst/nestsClient`](../..) — see `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md` -for the transport work-in-progress. +A compliant Kotlin/Android reference client ships in +[`amethyst/nestsClient`](../..). The reference server is the +nostrnests stack: +[`nostrnests/nests`](https://github.com/nostrnests/nests) (auth +sidecar) + +[`kixelated/moq`](https://github.com/kixelated/moq) (relay). -## Known divergences from current nostrnests/nests servers +This NIP describes the wire as the reference server speaks it — there +is no "vendor-neutral" alternative being trialled. Any server claiming +compliance MUST speak the moq-lite Lite-03 framing detailed above and +the `POST /auth` HTTP shape verbatim. -At the time of writing, existing `nostrnests/nests` deployments use: +## Reconciliation with previous spec drafts -- MoQ track namespace `[ "nests", ]` (two elements, `"nests"` prefix). - This NIP specifies `[ ]` (one element). Existing nests servers - SHOULD accept both for a transition period; new deployments SHOULD use the - one-element form. -- A `/api/v1/nests/` path convention. This NIP leaves the path - entirely to the `service` tag — servers are free to pick any path. +Earlier drafts of this NIP described the audio transport in terms of +IETF `draft-ietf-moq-transport` (CLIENT_SETUP / SERVER_SETUP, namespace +tuples, OBJECT_DATAGRAM) and a `GET /` HTTP +control plane returning `{endpoint, token, codec, sample_rate, …}`. -A "compliance" phase is proposed where a server can advertise -`"nip_xx": true` in its room-info JSON response to signal it implements -this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral -namespace when present, and fall back to the legacy nests convention when -absent. +Both have been **superseded**. nostrnests's reference deployment uses +`POST /auth` with a `{namespace, publish}` body returning `{token}`, +and runs moq-lite Lite-03 (single-string broadcast/track names, group- +per-uni-stream framing, no in-band SETUP). IETF MoQ-transport remains +a possible future ALPN, but no audio-rooms server implements it today +and this NIP no longer specifies it. ## Security considerations diff --git a/nestsClient/plans/2026-04-26-audio-rooms-completion.md b/nestsClient/plans/2026-04-26-audio-rooms-completion.md index 9c497b675..5de9fd4ab 100644 --- a/nestsClient/plans/2026-04-26-audio-rooms-completion.md +++ b/nestsClient/plans/2026-04-26-audio-rooms-completion.md @@ -1,278 +1,72 @@ -# Audio rooms — completion plan (2026-04-26) +# Audio rooms — completion plan -What's left between today's code and shippable audio rooms in Amethyst. +> **STATUS (2026-04-26 PM):** Listener and speaker paths are both live +> end-to-end on moq-lite Lite-03, the create-space flow ships, and the +> kind-10112 host-server list lives in Settings. What remains is +> hardening (reconnect / leave cleanup / per-speaker level metering), +> Nests-feature parity (chat, hand-raise approval, recordings — see +> the integration audit), and Desktop / iOS targets. +> `:nestsClient:jvmTest` (~140 unit tests) is green; integration tests +> behind `-DnestsInterop=true` work against a real Docker'd nostrnests +> stack. -> **STATUS UPDATE (2026-04-26 PM):** the interop test suite (phases 1–5 -> of the nostrnests work) uncovered that nostrnests runs on **moq-lite** -> (kixelated's variant), not IETF `draft-ietf-moq-transport-17`. Both -> the listener and speaker sides are now wired through moq-lite -> (`MoqLiteNestsListener` / `MoqLiteNestsSpeaker`), the WebTransport -> abstraction grew `incomingBidiStreams` + `openUniStream`, and Phase M1 -> manual validation against `nostrnests.com` should work end-to-end. -> See [`2026-04-26-moq-lite-gap.md`](2026-04-26-moq-lite-gap.md) for -> the exact wire spec + landing summary. +## Implementation state -## Where we are +### Transport + protocol -The transport stack is **done** and audited -([quic/plans/2026-04-26-quic-stack-status.md](../../quic/plans/2026-04-26-quic-stack-status.md)). -On top of it, `:nestsClient` already has: - -- HTTP control plane (`NestsClient.resolveRoom` — NIP-98 auth → room info) -- WebTransport adapter (`QuicWebTransportFactory` wires `:quic` into the - `WebTransportSession` interface) -- MoQ session — listener side: `MoqSession.client(...)` + `setup()` + - `subscribe(namespace, trackName, filter)` + control + datagram pumps -- Opus decode + audio playback chain: `MediaCodecOpusDecoder`, - `AudioTrackPlayer`, `AudioRoomPlayer` -- `NestsListener` API + `connectNestsListener` orchestration -- Audio capture primitives (`AudioRecordCapture`, `MediaCodecOpusEncoder`) - exist but are not wired into a publisher path - -Amethyst's `audiorooms/` UI parses NIP-53 events and renders rooms + -participant chips. It does NOT call `NestsListener` — there's no Connect -button, no audio output, no mute control wired. - -So the punch list is: app-side wiring → manual interop validation → speaker -path → backgrounding & polish. - -## Phase M1 — Listener-only MVP (1 week) - -**Goal:** open a real audio room from the Amethyst UI, hear one speaker. - -- Wire `connectNestsListener` into a `RememberRoomConnection` composable in - `amethyst/.../audiorooms/room/`. Lifecycle tied to `DisposableEffect`; - cancels on screen exit. -- Surface `NestsListenerState` in the UI: - - `Idle` / `Connecting` → show a spinner or chip "Connecting…" - - `Connected` → show "Audio connected" chip + auto-subscribe to the host's - speaker track (NIP-53 room's `p` tag with role `host`) - - `Failed(reason, cause)` → toast / inline message -- `AudioRoomPlayer` per subscription. Per the audio-rooms NIP draft - (`docs/plans/2026-04-22-nip-audio-rooms-draft.md`) one speaker = one track - name = ``; one `AudioRoomPlayer` per speaker. -- Mute toggle drives `AudioPlayer.setVolume(0f / 1f)` on the active player. - Mute at the player keeps the network running so unmute is instant. -- Backed by an `AudioRoomViewModel` in `commons/.../viewmodels/` so desktop - can reuse the orchestration once it gets WT. - -Tests: -- Manual: connect to `nostrnests.com`, open a known room, hear audio. -- Unit: `AudioRoomViewModel` state-flow transitions on - `NestsListenerState` updates. - -## Phase M2 — Multi-speaker + audience UX (3 days) - -- Subscribe to every `host` + `speaker` `p` tag, not just the first one. - Mix at the audio side (Android `AudioTrack` accepts multiple writers if - we use one shared track + downmix; cleaner: one `AudioTrack` per - subscription and let the OS mix). -- Show per-speaker level meters (if the encoder exposes RMS) or just a - speaking indicator driven by "objects received in last 200 ms". -- React to NIP-53 room event updates: a new speaker added to `p` → - open a subscription; a speaker removed → close one. - -## Phase M3 — Foreground service (2 days) - -- Android `MediaSessionService` with a media-style notification so - playback continues when the app backgrounds. -- Stop the service on: - - screen exit AND no other audio-room-screen is alive - - user dismisses the notification - - underlying `NestsListener` enters `Failed` or `Closed` -- Permission shim: `RECORD_AUDIO` is NOT needed for listener-only. - -## Phase M4 — Manual interop pass against `nostrnests.com` (3 days) - -This is the proof-of-life step before any speaker work. - -- Build a debug build with the listener flow above. -- Open one of the long-running test rooms hosted by nests. -- Confirm: connect succeeds; SUBSCRIBE_OK arrives; OBJECT_DATAGRAMs - decode through MediaCodec into audible audio. -- Anything that surfaces here goes into a follow-up audit / fix pass on - `:quic` or `:nestsClient`. We expect one or two issues — protocol drafts - drift, and we've only verified against aioquic, not a real MoQ relay. -- Capture a packet trace if anything fails so we can compare on-the-wire - bytes against a known-working JS client. - -## Phase M5 — Speaker path: MoQ publisher (1 week) — **DONE (via moq-lite, not IETF MoQ)** - -> **Update:** the original plan called for ANNOUNCE / OBJECT emission on -> the IETF `MoqSession`. Once the moq-lite gap was discovered, the -> speaker path was implemented on the `MoqLiteSession` instead — see -> [`2026-04-26-moq-lite-gap.md`](2026-04-26-moq-lite-gap.md) phase -> 5c-speaker. The wire shape is different (single-string broadcast + -> `audio/data` track, group-per-uni-stream framing) but the -> [NestsSpeaker] / [BroadcastHandle] surface is unchanged. - -Original IETF MoQ design (kept as reference): - -The big one. `MoqSession` only does subscribe today; it needs ANNOUNCE + -OBJECT emission. - -Required MoQ messages to encode + decode: - -| Message | Direction | Status | +| Surface | Status | Notes | |---|---|---| -| ANNOUNCE | client → server | not implemented | -| ANNOUNCE_OK / ANNOUNCE_ERROR | server → client | decode + match-by-namespace | -| ANNOUNCE_CANCEL | server → client | decode + signal publisher to stop | -| UNANNOUNCE | client → server | encode | -| SUBSCRIBE | server → client (we're publisher) | accept + map to our track sink | -| SUBSCRIBE_OK / SUBSCRIBE_ERROR | client → server | encode | -| SUBSCRIBE_DONE | client → server | encode on track end | -| OBJECT_DATAGRAM (publish-side) | client → server | encode + emit | +| `:quic` v1 + HTTP/3 + WebTransport | ✅ done | RFC 9000 + 9220; auditor passes | +| moq-lite Lite-03 codec (announce / subscribe / group) | ✅ done | `moq/lite/`, full unit suite | +| moq-lite session (listener + publisher) | ✅ done | `MoqLiteSession.client(...)` | +| `WebTransportSession.incomingBidiStreams` + `openUniStream` | ✅ done | needed for publisher | +| IETF `draft-ietf-moq-transport-17` codec | ✅ done | reference impl, no production caller | -API we need on `MoqSession`: +### `:nestsClient` -```kotlin -suspend fun announce( - namespace: TrackNamespace, - parameters: List = emptyList(), -): AnnounceHandle - -interface AnnounceHandle { - /** New publisher per track name we serve under this namespace. */ - suspend fun openTrack(name: ByteArray): TrackPublisher - /** Stop announcing; sends UNANNOUNCE + closes any open track publishers. */ - suspend fun unannounce() -} - -interface TrackPublisher { - /** Push one OBJECT_DATAGRAM. group/objectId are managed internally - * per the audio-rooms NIP. */ - suspend fun send(payload: ByteArray) - suspend fun close() -} -``` - -Internal additions: -- `pendingAnnounces` keyed by namespace, like the existing - `pendingSubscribes` -- inbound-SUBSCRIBE routing: when the server SUBSCRIBEs, we look up the - publisher by namespace+name and start delivering its objects with the - server-assigned subscribeId/trackAlias -- group/object id management: monotonic group per - `TrackPublisher`, object id zero-reset per group; reflect this in the - emitted `OBJECT_DATAGRAM` header - -Tests: -- `MoqSession` unit tests for ANNOUNCE round-trip via `FakeWebTransport` -- Integration: a publisher sends 100 Opus-shaped payloads through to a - matching subscriber, all received with intact group/object ids - -## Phase M6 — Capture → encode → publish (3 days) — **DONE** - -> Both `AudioRoomBroadcaster` (drives the IETF `TrackPublisher`) and -> `AudioRoomMoqLiteBroadcaster` (drives `MoqLitePublisherHandle`) -> exist. The Android actuals (`AudioRecordCapture`, -> `MediaCodecOpusEncoder`) are wired into the production speaker path. - -The inverse of `AudioRoomPlayer`: - -- `AudioCaptureSource` (commonMain interface) with platform actuals on - `AudioRecordCapture` (Android) and a desktop one later -- `AudioRoomBroadcaster` orchestrates: pull PCM frames from the capture → - feed `MediaCodecOpusEncoder` → push the resulting Opus packet into - `TrackPublisher.send` -- `RECORD_AUDIO` permission gate — surface on first-tap of the talk button -- Push-to-talk vs always-on toggle: at the API level, just `start()` / - `stop()` on the broadcaster; the UI decides - -## Phase M7 — `NestsSpeaker` API (2 days) — **DONE** - -> `NestsSpeaker` interface + `MoqLiteNestsSpeaker` implementation + -> `connectNestsSpeaker` orchestration are all live and reach the -> `Connected` state against a connected moq-lite session. The Compose -> "Talk" button + mute UI in `AudioRoomActivityContent` calls -> `viewModel.startBroadcasting()` / `setMuted(...)` which routes to -> `BroadcastHandle.setMuted` and the moq-lite publisher. - -Mirror of `NestsListener` for hosts/speakers: - -```kotlin -interface NestsSpeaker { - val state: StateFlow - suspend fun startBroadcasting(): BroadcastHandle - suspend fun close() -} - -interface BroadcastHandle { - suspend fun setMuted(muted: Boolean) - suspend fun close() -} -``` - -Same `connectNestsSpeaker` orchestration as `connectNestsListener` but the -post-`setup` step is `announce(...)` instead of `subscribe(...)`. - -UI: -- Talk button only enabled when our pubkey is in the room's `p` tags with - role `host` or `speaker` -- "Live" indicator while broadcasting, level meter from the encoder -- Mute / unmute drives `BroadcastHandle.setMuted` - -## Phase M8 — App polish (3-5 days) - -- Connection-recovery: `NestsListener` exposes `reconnect()`; the screen - retries on `Failed` after a short backoff -- Room-leave cleanup: on screen exit, send UNSUBSCRIBE + UNANNOUNCE before - closing the WT session (audit-4 / 5 already wired the - `WtCloseSession` capsule emit on `close()`) -- Surface server `peerGoawayProtocolError` and the various - `NestsListenerState.Failed` reasons as user-readable messages -- iOS: stub everything in `iosMain` with `expect`s that error cleanly until - iOS audio capture/playback land - -## Phase M9 — Backgrounding for speakers (2 days) - -Different from M3 because capture has stricter Android rules: -- Foreground service type `microphone` (Android 14+ requires this) -- Notification with prominent "Speaking" indicator + mute action - -## Out of scope for this plan - -- **Recording / saving** room audio. -- **Server-mixed audio.** Each speaker is a separate track per the NIP - draft; mixing is client-side. -- **Video.** We support audio only. -- **Accessibility transcription.** -- **Desktop audio capture** (until Compose Desktop has a stable - `AudioInput` API; today's options are JNA-heavy). - -## Timeline - -| Phase | Days | Cumulative | +| Surface | Status | Notes | |---|---|---| -| M1 Listener wire-up | 5 | 5 | -| M2 Multi-speaker | 3 | 8 | -| M3 Foreground listener | 2 | 10 | -| M4 Real-server interop | 3 | 13 | -| M5 MoQ publisher | 5 | 18 | -| M6 Capture + encode | 3 | 21 | -| M7 NestsSpeaker | 2 | 23 | -| M8 Polish | 4 | 27 | -| M9 Foreground speaker | 2 | 29 | +| HTTP `/auth` JWT mint (NIP-98 → ES256 JWT) | ✅ done | `OkHttpNestsClient.mintToken` | +| `connectNestsListener` → `MoqLiteNestsListener` | ✅ done | path = `/?jwt=` | +| `connectNestsSpeaker` → `MoqLiteNestsSpeaker` | ✅ done | publishes `audio/data` track | +| `AudioRecordCapture` + `MediaCodecOpusEncoder` | ✅ done | Android actuals | +| `MediaCodecOpusDecoder` + `AudioTrackPlayer` | ✅ done | Android actuals | +| `AudioRoomMoqLiteBroadcaster` + `AudioRoomPlayer` | ✅ done | encode / decode pumps | +| Interop harness (Docker compose nostrnests stack) | ✅ done | `NostrNestsHarness`, 4 test classes | -≈ **6 weeks** to ship full audio rooms (listener + speaker + Android polish). -≈ **2 weeks** to ship listener-only (M1+M3+M4) which is the 95% case for -audience members. +### Amethyst (Android UI) -## Stop conditions +| Surface | Status | Notes | +|---|---|---| +| `AudioRoomsScreen` feed of kind-30312 events | ✅ done | from followed users | +| `AudioRoomJoinCard` → launches `AudioRoomActivity` | ✅ done | passes service / endpoint / hostPubkey | +| `AudioRoomActivity` (full screen + PIP) | ✅ done | lifecycle anchor | +| `AudioRoomViewModel` (listener + speaker state) | ✅ done | `commons/.../viewmodels/` | +| Foreground service (`AudioRoomForegroundService`) | ✅ done | listening + microphone variants | +| Mute / unmute / hand-raise UI | ✅ done | drives `BroadcastHandle.setMuted` + presence | +| Kind-10312 presence heartbeat | ✅ done | 30 s + debounced state-change publish | +| **"Start space" FAB → `CreateAudioRoomSheet`** | ✅ done | publishes kind-30312 with caller as host | +| **Settings → Audio-room servers (kind 10112)** | ✅ done | replaceable list, defaults to `nostrnests.com` | -- **M4 reveals the QUIC stack can't reach `nostrnests.com`** — drop into a - protocol-comparison pass (likely a draft-version mismatch or a small - framing bug). Up to 1 wk of `:quic` adjustment, otherwise we ship behind - a feature flag and chase interop async. -- **MediaCodec Opus is missing on a target device.** Android 10+ ships the - decoder; for older devices we'd need a software Opus, which is out of - scope. +### Pending + +| Item | Phase | Effort | +|---|---|---| +| Reconnect / retry on listener Failed | M8 | 1 d | +| Per-speaker level meters (RMS from decoder output) | M8 | 1 d | +| Connection-failure UX copy (typed reasons → strings) | M8 | 0.5 d | +| Desktop audio capture + playback | future | gated on Compose Desktop AudioInput API | +| iOS audio capture + playback | future | new `iosMain` actuals | +| Nests-feature parity (chat, mod, recordings, …) | see integration audit | varies | ## Pointers +- moq-lite wire spec + IETF gap: `nestsClient/plans/2026-04-26-moq-lite-gap.md` +- Nostrnests integration audit (gaps + roadmap): see most recent doc in `nestsClient/plans/` - QUIC stack status: `quic/plans/2026-04-26-quic-stack-status.md` -- Audio-rooms NIP draft: `docs/plans/2026-04-22-nip-audio-rooms-draft.md` -- Original (frozen) QUIC plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md` -- Existing listener entry point: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt` -- App-side audio-room screen: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/` +- Audio-rooms NIP draft (needs refresh after the moq-lite findings): + `docs/plans/2026-04-22-nip-audio-rooms-draft.md` +- Listener entry: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt` +- Speaker entry: same file (`connectNestsSpeaker`) +- Create-space sheet: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/` +- Servers settings: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/` diff --git a/nestsClient/plans/2026-04-26-moq-lite-gap.md b/nestsClient/plans/2026-04-26-moq-lite-gap.md index bf4924df8..9f1ec0cfc 100644 --- a/nestsClient/plans/2026-04-26-moq-lite-gap.md +++ b/nestsClient/plans/2026-04-26-moq-lite-gap.md @@ -1,9 +1,11 @@ # Plan: bridge the moq-lite protocol gap -**Status:** **listener AND speaker sides done** (phases 5a → 5d, commit -range `fb47a4c` → `5914e9e` + the speaker phase 5c-speaker landing in -this PR). Default `:nestsClient:jvmTest` suite passes; downstream -`:commons` and `:amethyst` compile clean. +**Status:** ✅ **DONE** — listener + speaker on moq-lite Lite-03 against +the real nostrnests stack. Phases 5a → 5d landed (`fb47a4c` → +`71cf99d`); follow-up cleanup + harness fixes shipped through `015b0d7`. +Default `:nestsClient:jvmTest` passes; integration tests gated by +`-DnestsInterop=true` work end-to-end against a Docker'd nostrnests +deployment. **Origin:** discovered while writing the nostrnests interop test suite (phases 1–4). @@ -299,9 +301,20 @@ host runs them with `-DnestsInterop=true`. ## When picking up -- Read `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/` - for the JS reference. -- Read `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version, - path}.rs` for the canonical Rust implementation. -- The nests-side `claims.put = [pubkey]` rule is in - `moq-auth/src/index.ts:160-166`. +This doc captures the wire spec used during implementation. For the +shipped surface, start from: + +- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/` + — `MoqLiteCodec`, `MoqLiteSession`, `MoqLitePath`, `MoqLiteFraming`. +- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/` + `MoqLiteNestsListener.kt` / `MoqLiteNestsSpeaker.kt`. +- `nestsClient/src/jvmTest/.../interop/` — Docker-driven interop tests + (auth, round-trip, multi-peer, endpoint smoke). + +For raw protocol reference: + +- `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/` + — JS reference once the harness has run once. +- `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,path}.rs` + — canonical Rust implementation. +- `moq-auth/src/index.ts:160-166` — `claims.put = [pubkey]` rule. diff --git a/nestsClient/plans/2026-04-26-nostrnests-integration-audit.md b/nestsClient/plans/2026-04-26-nostrnests-integration-audit.md new file mode 100644 index 000000000..eaed76d2c --- /dev/null +++ b/nestsClient/plans/2026-04-26-nostrnests-integration-audit.md @@ -0,0 +1,232 @@ +# Nostrnests integration audit (2026-04-26) + +What `nostrnests/NestsUI-v2` does that Amethyst doesn't yet — sourced +from a code-walk of the React app, the moq-auth sidecar, and the +moq-lite reference. Use this as the punchlist for the next chunks of +audio-room work. + +> **Already shipped** (don't re-add to the gap list): +> HTTP `/auth` JWT mint, moq-lite Lite-03 listener + speaker, kind +> 30312 with title/summary/image/status/service/endpoint + single host +> participant, kind 10312 presence with `hand` + `muted` + final +> "leaving" emit, kind 10112 host-server list (Settings UI), foreground +> service, PIP, mute, "Start space" sheet that publishes the kind-30312 +> with the user as host. + +## What `API.md` is **NOT** + +`nostrnests/API.md` documents a LiveKit-era HTTP surface +(`PUT /api/v1/nests`, `/permissions`, `/recording*`, `/info`, `/guest`). +**That whole file is dead** — the moq-lite refactor dropped every +endpoint except `POST /auth` and `GET /.well-known/jwks.json`. There +is no recording surface, no permissions endpoint, no `/info`, no +`/guest`. Don't waste implementation time on those. + +## Tier 1 — Visible, low-effort (ship first) + +### 1. Live chat (kind 1311) — ~1 day, **user-visible** +NestsUI publishes `{kind:1311, content, tags:[["a", roomATag]]}` and +subscribes to `{kinds:[1311], "#a":[roomATag]}` for the in-room chat +panel. Quartz already has `LiveActivitiesChatMessageEvent` at +`quartz/.../nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt:132` +— just not wired into `AudioRoomFullScreen`. Need a chat pane + +`RoomChatViewModel` reading `LocalCache` filtered by `#a` and a +`AudioRoomsSubAssembler` subscription. + +Source: `NestsUI-v2/src/components/WriteMessage.tsx:31-37`, +`hooks/useChatMessages.ts:31-66`. + +### 2. Reactions (kind 7 + custom emoji) — ~1 day, **user-visible** +Emoji reactions tagged `["a", roomATag]`; NIP-30 +`["emoji", shortcode, url]` for custom. Floating overlay shows last +30 s of reactions on the avatar. `ReactionEvent` already in Quartz at +`quartz/.../nip25Reactions/ReactionEvent.kt:73`. Need a small picker + +per-avatar overlay. + +Source: `components/ReactionsButton.tsx:31-53`, +`hooks/useRoomReactions.ts:14-22` (queries kinds 7 + 9735), +`components/ReactionOverlay.tsx`, +`RoomContextProvider.tsx:105` (30-second visibility window). + +### 3. Speaker / admin promotion via `p`-tag role markers — 2 days, **user-visible** +Role lives in the `p` tag's 4th element: +`["p", pubkey, "", "host"|"admin"|"speaker"]`. Promotion = +host/admin re-publishes the kind-30312 with the target's role updated; +demotion / "remove from stage" = drop the role. + +Amethyst today only emits a single host. Need to: +1. Parse `admin` and `speaker` roles on incoming events +2. Add a host UI "edit room → set role" path that re-publishes 30312 +3. Gate "publish microphone" on having `host|admin|speaker` (a plain + listener should NOT auto-publish unless promoted) + +Source: `lib/const.ts:27-31` (ParticipantRole), `lib/room.ts:66-74` +(parser), `components/ProfileCard.tsx:106-119` (`updateRoomParticipant`), +`hooks/useIsAdmin.ts:25-31`. + +### 4. Hand-raise queue surface for hosts — ~0.5 day after #3, **user-visible** +There is **no separate "approval" event**. Host inspects the kind-10312 +presence list, sees `["hand","1"]`, and uses the same role re-publish +flow above to promote. Just a "raised hands" section in the host's +participant grid. Source: `hooks/useRoomPresence.ts:14-39`, +`ParticipantsGrid.tsx:69`. + +### 5. Kick (kind 4312 admin command) — ~1 day, **user-visible** +Ephemeral event: +``` +{ kind: 4312, content: "", + tags: [["a", roomATag], ["p", target], ["action", "kick"]] } +``` +Targets watch for it; if signed by host/admin in last 60 s, they +self-disconnect. Host also drops the target's `p`-tag from the 30312. + +Need a new Quartz `AdminCommandEvent` (kind 4312, currently absent), +client subscription, and auto-disconnect handler. + +Source: `lib/const.ts:18` (`ADMIN_COMMAND = 4312`), +`components/ProfileCard.tsx:121-134`, `hooks/useAdminCommands.ts:38-74`. + +### 6. Edit room / close room — ~0.5 day, **user-visible** +Host re-publishes the same `d`-tag 30312 with new title/summary/image, +or with `["status","ended"]` to close. Amethyst's CreateAudioRoomSheet +covers create; need a host-only "edit" sheet for an existing room. + +Source: `components/EditRoomDialog.tsx:125-213`. Theme tags (`c`, `f`, +`bg`) can be ignored for now (see #14). + +### 7. Scheduled / planned rooms — 2-4 hours, **user-visible** +`["status","planned"]` + `["starts",""]`. `StatusTag.STATUS` and +`starts` already exist in Quartz; the create-sheet just doesn't expose +a date/time picker yet. + +Source: `pages/NewRoom.tsx:50-58`. + +### 8. Listener counter ("N listening") — ~2 hours, **user-visible** +Subscribe to `{kinds:[10312], "#a":[roomATag], since: now-300}`, +dedupe by pubkey, render `presenceList.length`. Currently Amethyst +emits its own presence but doesn't read others'. Unblocks #4 and #9. + +Source: `pages/RoomPage.tsx:67-68`, `hooks/useRoomPresence.ts:14-37`. + +## Tier 2 — Visible, medium-effort + +### 9. Participant grid (speakers vs listeners) — 2-3 days, **user-visible** +Render every pubkey from +1. MeetingSpace `p` tags (with their role) +2. moq-lite announcements (active publishers, surfaced via the listener + session's announce flow) +3. Recent kind-10312 presence + +Speakers = `host|admin|speaker` minus presences with `["onstage","0"]`. + +Source: `components/ParticipantsGrid.tsx:75-101`. Note new presence +tags Amethyst doesn't emit yet — `["publishing","0|1"]`, +`["onstage","0|1"]` — see #10. + +### 10. Augment kind-10312 with `publishing` + `onstage` tags — ~1 hour, infrastructure +`usePresence.ts:33-41` emits +``` +["a", roomATag], ["hand","0|1"], ["publishing","0|1"], ["muted","0|1"], ["onstage","0|1"] +``` +"Leave the stage" UX hinges on `onstage=0`. Extend the existing 10312 +emitter in `AudioRoomActivityContent.publishPresence`. + +### 11. Per-participant context menu — 1 day, **user-visible** +Tap an avatar → View profile, Follow (kind 3), Mute (kind 10000), Zap. +All exist elsewhere in Amethyst — assemble in a room-scoped sheet. + +Source: `components/ProfileCard.tsx:177-258`. + +### 12. Zap support inside the room — ~0.5 day, **user-visible** +Standard NIP-57 zap of the room event itself or a specific speaker. +Reactions hook also pulls `kind:9735` so paid zaps appear in the +reaction stream. Reuse Amethyst's existing zap UI. + +Source: `components/ZapDialog.tsx`, `hooks/useZaps.ts`. + +### 13. Share room (`naddr` deep link) — ~0.5 day, **user-visible** +`ShareDialog` builds an `naddr` from the room event and offers +"share to Nostr" (publishes a kind 1 referencing the naddr) plus +clipboard. Quartz already has `NAddress.create`. + +Source: `components/ShareDialog.tsx:45`, +`lib/room.ts:77-83` (`buildRoomNaddr`). + +## Tier 3 — Larger / lower priority + +### 14. Room theming (tags `c`, `f`, `bg` + kinds 36767 / 16767) — 3-5 days +Inline color triplet `["c", hex, "background|text|primary"]`, font +`["f", family, url]`, background `["bg", "url ", "mode "]`. +Optional `a`-tag pointing to a kind-36767 Ditto theme; per-user +kind-16767 profile theme. + +Effort range: full theming is 3-5 days; just *parsing* the tags so +themed rooms don't render badly is ~0.5 day. Compose Multiplatform +can pull dynamic colors but font loading is platform-specific. + +Source: `lib/const.ts:21-24`, `lib/ditto-theme.ts`, +`components/ThemeChooser.tsx`, `pages/NewRoom.tsx:69-88`. + +### 15. Background audio + wake-lock audit — ~2 hours +NestsUI uses `useWakeLock`, `useAudioKeepAlive`, `useBackgroundAudio`. +Amethyst already has `AudioRoomForegroundService` + PIP; just confirm +`PARTIAL_WAKE_LOCK` is acquired during a broadcast. Probably already +covered. + +## Tier 4 — Infrastructure (mostly already correct) + +### 16. moq-auth token lifetime — ~2 hours +`/auth` JWT lives 600 s (10 min). No refresh endpoint — re-mint on +expiry. Confirm Amethyst re-mints before the token's `exp` on long +sessions. + +JWT claims emitted by the sidecar: +- `root` = the `namespace` from the request body +- `get: [""]` (read-anything-under-root, for subscribers) +- `put: []` (publish only your own sub-namespace, for `publish:true`) +- `iat` / `exp` + +Source: `moq-auth/src/index.ts:160-166`. + +### 17. moq-lite features unused (mostly intentional) +| Feature | NestsUI | Amethyst | Verdict | +|---|---|---|---| +| Multi-track / video | not used (audio only) | not used | — | +| Fetch / replay | not used | not used | — | +| Per-track priority / bitrate probes | not used | not used | — | +| `Connection.Reload` auto-reconnect (1s→2s→…→30s backoff) | yes | **MAYBE GAP** | confirm `MoqLiteSession` reconnects with backoff on transport failure; if not, add ~0.5 day. | +| WebSocket transport fallback | yes (browser) | no (WebTransport only over QUIC) | not a gap on Android | +| Announcement-driven participant discovery | yes | needs to expose announce flow on `MoqLiteNestsListener` | small wire-up; covered by #9 | + +Source: `transport/moq-transport.ts:145-151, 257-265, 386-422`. + +### 18. NIP-71 / hashtags / spotlight / `ends` — **not a gap** +None of these are emitted by NestsUI on kind 30312. The only tags it +writes: `d, title, summary, status, starts, color, image, streaming, +auth, relays, p`, plus theme tags. No NIP-71 streaming tags, no +hashtags, no spotlight, no `ends`. Don't chase parity here. + +## Recommended punchlist order + +1. **Listener-side presence aggregation + listener counter** (#8, #10) +2. **Live chat panel** (#1) +3. **Reactions** (#2) +4. **Participant grid + per-avatar context menu + zap** (#9, #11, #12) +5. **Role parsing + hand-raise queue + promote / demote / kick** (#3, #4, #5) +6. **Edit room / close room** (#6) + **scheduled rooms** (#7) +7. **Share via naddr** (#13) +8. **moq-auth token refresh sanity check** (#16) + **`Connection.Reload` + backoff confirmation** (#17) +9. **Room theme parsing — graceful fallback only** (#14) + +Items 1-3 give you "feels like a real audio room" inside ~3 days. +Items 4-5 unlock the full host workflow inside another ~1 week. +Everything else is polish. + +## Key files for the implementer + +- Listener: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsListener.kt` +- MeetingSpace event: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt` +- Live-chat event (kind 1311): `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt` +- Full-screen room UI: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt` +- Subscriptions for the rooms feed: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt`