docs(audio-rooms): refresh against shipped state + nostrnests gap audit

Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.

  - 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
    layout: implementation table for every protocol/transport/UI
    surface, "pending" table for the remaining items (reconnect,
    level meters, Desktop / iOS, Nests parity), pointers section
    refreshed.
  - 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
    that landed it (fb47a4c71cf99d015b0d7); "When picking up"
    section now points at the shipped surface first, raw protocol
    references second.
  - 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
    today's nostrnests reality:
      * status banner up top calling out the revision
      * dependencies dropped IETF MoQ-transport, added moq-lite
        Lite-03 + ALPN "moq-lite-03"
      * HTTP control plane: GET <service>/<room-d-tag> → POST /auth
        with {namespace, publish}, returning {token}; documented the
        JWT claim shape (root, get, put), 600 s lifetime, regex
        on `namespace`, JWKS endpoint, error matrix
      * Audio transport: replaced IETF SETUP / TrackNamespace tuples
        / OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
        per-bidi message types, group uni streams, audio/data track,
        no in-band SETUP, FIN-as-unsubscribe semantics)
      * New event-kind sections: kind 4312 (admin command / kick),
        kind 10112 (audio-room server list)
      * Reconciliation section explaining what changed from the
        original draft and why
  - NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
    every nostrnests/NestsUI feature we don't yet ship, sourced from
    a code-walk of the React app + moq-auth + API.md (which is
    LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
    reactions, role parsing + promotion, hand-raise queue, kick
    (kind 4312), edit/close room, scheduled rooms, listener counter.
    Tier 2: participant grid, augmented presence tags
    (publishing/onstage), per-avatar context menu + zap, share via
    naddr. Tier 3: room theming. Tier 4: token-refresh +
    Connection.Reload sanity checks.

Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
This commit is contained in:
Claude
2026-04-26 19:23:41 +00:00
parent 015b0d7dac
commit 8b5af5d496
4 changed files with 526 additions and 401 deletions
+214 -128
View File
@@ -2,6 +2,12 @@
`draft` `optional` `draft` `optional`
> **Revision 2026-04-26.** Replaces the IETF MoQ-transport / `GET <service>/<room-d-tag>`
> 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 ## Abstract
This NIP specifies the client/server control plane and real-time audio 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 defines only *what* a room is) and what an audio-capable client must do
to actually hear and speak in it. to actually hear and speak in it.
A standard, vendor-neutral profile lets multiple server implementations The transport is **moq-lite Lite-03** over WebTransport with a thin
interoperate with multiple clients. A client that follows this NIP will NIP-98-authenticated HTTP `/auth` endpoint that mints an ES256 JWT. A
work against any server that advertises compliance, and vice versa. client that follows this NIP will work against any server speaking the
same wire shape.
## Dependencies ## Dependencies
@@ -21,9 +28,12 @@ work against any server that advertises compliance, and vice versa.
and kind `1311` (Live Activity Chat). and kind `1311` (Live Activity Chat).
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md) - [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
defines kind `27235` and the `Authorization: Nostr <base64-event>` header. defines kind `27235` and the `Authorization: Nostr <base64-event>` header.
- [IETF `moq-transport`](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) - [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)
and [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) carries the audio transport.
provide the audio transport substrate. - **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 ## 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 Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag
pointing at the `30312` event. 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:<host-pubkey>:<room-d-tag>"],
["p", "<target-pubkey>"],
["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", <baseUrl>]` 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 ## HTTP control plane
### Base URL ### Base URL
The `service` tag from the kind `30312` event is the base URL. All control- The `service` tag from the kind `30312` event is the base URL of the
plane requests are constructed as: auth sidecar (a.k.a. `moq-auth`). It exposes exactly two routes:
``` ```
GET <service>/<room-d-tag>room-info / join POST <service>/auth mint a JWT for one room+role
GET <service>/.well-known/jwks.json — public keys for JWT verification
``` ```
where `<room-d-tag>` is the `d` tag value of the kind `30312` event, The server SHOULD also expose `GET <service>/health` returning
URL-path-encoded. `{"status":"ok"}` for liveness probes. Anything else SHOULD return 404.
The server MAY expose additional paths under the same base (e.g.
`<service>/` for server metadata, `<service>/.well-known/nostr-audio-rooms`
for discovery). This NIP only specifies `<service>/<room-d-tag>`.
### Authentication ### Authentication
Every request MUST carry a NIP-98 `Authorization` header: Every `POST /auth` request MUST carry a NIP-98 `Authorization` header:
``` ```
Authorization: Nostr <base64(kind-27235-event)> Authorization: Nostr <base64(kind-27235-event)>
@@ -101,155 +146,191 @@ Authorization: Nostr <base64(kind-27235-event)>
The kind `27235` event MUST have: The kind `27235` event MUST have:
- `["u", "<fully-qualified-URL-being-requested>"]` - `["u", "<fully-qualified-URL-being-requested>"]`
- `["method", "GET"]` (or `POST`, `DELETE`, etc. matching the request verb) - `["method", "POST"]`
- `["payload", "<sha256-hex of the request body>"]` (NIP-98 §2.2)
- `created_at` within 60 s of the server's clock - `created_at` within 60 s of the server's clock
- A valid signature - A valid signature
The server SHOULD reject requests older than 60 s with `401 Unauthorized`. The server MUST 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).
### Join / room-info response ### Join / room-info response
`GET <service>/<room-d-tag>` with a valid NIP-98 header returns a JSON body `POST <service>/auth` body:
with `Content-Type: application/json`:
```json ```json
{ { "namespace": "nests/30312:<host-pubkey>:<room-d-tag>", "publish": false }
"endpoint": "https://relay.example.com:4443/moq",
"token": "eyJhbGciOi…",
"transport": "webtransport",
"codec": "opus",
"sample_rate": 48000,
"frame_duration_ms": 20,
"moq_version": "draft-17"
}
``` ```
Fields: The `namespace` MUST match the regex
`^nests/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$`
(`<event-kind>:<host-pubkey>:<d-tag>`, where `<event-kind>` 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 | Response on success:
|---|---|---|---|
| `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. |
Unknown fields MUST be ignored by the client to allow forward-compatible ```json
server extensions. { "token": "eyJhbGciOi…" }
```
Error responses are standard HTTP status codes with an optional JSON body The `token` is an ES256 JWT signed by the `service` server's keypair;
`{"error": "<short-code>", "reason": "<human-readable>"}`: its public key is available at `<service>/.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` | `[<requester's pubkey>]` — publisher may only `ANNOUNCE` under `root/<pubkey>`. Present only when `publish: true`. |
| `iat` / `exp` | Standard. Token lifetime is **600 s**; clients re-mint on expiry. |
Error responses:
| Status | When | | Status | When |
|---|---| |---|---|
| `401` | NIP-98 missing, expired, or signature invalid. | | `400` | Body missing / malformed JSON / `namespace` fails the regex. |
| `403` | Signer isn't allowed in this room (e.g. room `closed`, signer blocked). | | `401` | Authorization missing, signature invalid, `u`/`method`/`payload` mismatch, or `created_at` outside ±60 s. |
| `404` | Room `d` tag unknown to this server. | | `429` | Rate-limited (≥ 20 mint requests per IP per 60 s). |
| `410` | Room has ended. |
| `503` | Server is healthy but audio backend is unavailable. | 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 ## Audio transport
### WebTransport ### WebTransport
Clients open a WebTransport session against the `endpoint` URL using the Clients open a WebTransport session against the `endpoint` URL using
Extended CONNECT handshake ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) the Extended CONNECT handshake
+ the [WebTransport-HTTP/3 draft](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)). ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) +
The HTTP CONNECT request MUST carry: [`webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)):
``` ```
:method: CONNECT :method: CONNECT
:protocol: webtransport :protocol: webtransport
:scheme: https :scheme: https
:authority: <host[:port] from endpoint> :authority: <host[:port] from endpoint>
:path: <path from endpoint> :path: /<namespace>?jwt=<token>
Authorization: Bearer <token from room-info response>
``` ```
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)), Servers MUST advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`,
- advertise `SETTINGS_ENABLE_WEBTRANSPORT = 1`, `SETTINGS_ENABLE_WEBTRANSPORT = 1`, and `SETTINGS_H3_DATAGRAM = 1`.
- advertise `SETTINGS_H3_DATAGRAM = 1` ([RFC 9297](https://www.rfc-editor.org/rfc/rfc9297)).
### MoQ session ### moq-lite session
After the WebTransport session is open, the client opens its first The application-level framing is **moq-lite Lite-03** (kixelated/moq).
bidirectional stream as the MoQ control stream and sends `CLIENT_SETUP` The variant is selected by the WebTransport ALPN — clients SHOULD
advertising the `moq_version` from the room-info response. The server replies advertise `"moq-lite-03"` (and MAY include the legacy `"moql"`).
with `SERVER_SETUP` selecting that version, or closes the session.
### 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 ### Per-bidi `ControlType` discriminator
clients under:
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=<own pubkey>`. 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 = [ <room-d-tag> ] DataType varint = 0 (Group)
track_name = <speaker-pubkey-hex> (64 lowercase hex chars) GroupHeader (size-prefixed): { subscribe: u62, sequence: u62 }
frames until QUIC FIN: { size: varint, payload: <Opus packet> }
``` ```
The namespace is a **one-element tuple** containing the room's `d` tag. It is ### Listener — subscriber path
intentionally vendor-neutral: there is no `"nests"` or server-brand prefix.
Rationale: the namespace is uniquely keyed by the NIP-53 room id and is A listener opens its own client-initiated bidis to the relay:
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.
### Audio objects - `Announce` (ControlType=1) with
`AnnouncePlease { prefix: <empty or namespace 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 | Field | Value |
produced by `libopus` / Android `MediaCodec("audio/opus")` encoder output. |---|---|
- `sample_rate` from the room-info response (default 48 000 Hz). | `broadcast` | `<speaker-pubkey-hex>` (single string, no `nests/` prefix) |
- `frame_duration_ms` from the room-info response (default 20 ms). | `track` | `"audio/data"` |
- Mono (single channel), signed 16-bit PCM domain, VBR encoding. | 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. ### Audio frame format
- **STREAM_HEADER_SUBGROUP** uni-streams (reliable) — MAY be used by servers
that need delivery guarantees; clients MUST support receiving both.
`group_id` on each object is the speaker's monotonic group counter (one Each frame payload on the `audio/data` track is one **Opus packet** in
group per speaker session). `object_id` is the zero-based Opus packet index raw form (no Ogg, no TOC prefix), as produced by `libopus` /
within the group. `publisher_priority` is `0x80` unless the server has Android `MediaCodec("audio/opus")` output:
reason to vary it.
### 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. There is **no per-frame envelope** beyond the size varint — no
The server MUST accept SUBSCRIBEs from any authenticated client (subject to timestamp, no codec config, no flags. Receivers reconstruct timing
its access control). from frame arrival + the group sequence.
A **speaker** ANNOUNCEs `[ <room-d-tag> ]` and publishes objects on track ### Unsubscribe / close
name `<speaker-pubkey-hex>`. 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.
### 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. ### Leaving the room (Nostr-side)
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 In addition to the wire-level cleanup above, a leaving client SHOULD:
other peers) with `["muted","1"]` and without `["hand","1"]`.
4. Close the WebTransport session with capsule type `0x2843` 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`). (`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 ## Chat
Per NIP-53 kind `1311`. No additions. Per NIP-53 kind `1311`. No additions.
@@ -291,26 +372,31 @@ A client claiming this NIP MUST:
## Reference implementation ## Reference implementation
A compliant Kotlin/Android reference client is in development at A compliant Kotlin/Android reference client ships in
[`amethyst/nestsClient`](../..) — see `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md` [`amethyst/nestsClient`](../..). The reference server is the
for the transport work-in-progress. 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", <d-tag> ]` (two elements, `"nests"` prefix). Earlier drafts of this NIP described the audio transport in terms of
This NIP specifies `[ <d-tag> ]` (one element). Existing nests servers IETF `draft-ietf-moq-transport` (CLIENT_SETUP / SERVER_SETUP, namespace
SHOULD accept both for a transition period; new deployments SHOULD use the tuples, OBJECT_DATAGRAM) and a `GET <service>/<room-d-tag>` HTTP
one-element form. control plane returning `{endpoint, token, codec, sample_rate, …}`.
- A `/api/v1/nests/<d-tag>` path convention. This NIP leaves the path
entirely to the `service` tag — servers are free to pick any path.
A "compliance" phase is proposed where a server can advertise Both have been **superseded**. nostrnests's reference deployment uses
`"nip_xx": true` in its room-info JSON response to signal it implements `POST /auth` with a `{namespace, publish}` body returning `{token}`,
this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral and runs moq-lite Lite-03 (single-string broadcast/track names, group-
namespace when present, and fall back to the legacy nests convention when per-uni-stream framing, no in-band SETUP). IETF MoQ-transport remains
absent. a possible future ALPN, but no audio-rooms server implements it today
and this NIP no longer specifies it.
## Security considerations ## Security considerations
@@ -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 15 ## Implementation state
> 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.
## Where we are ### Transport + protocol
The transport stack is **done** and audited | Surface | Status | Notes |
([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 = `<speaker-pubkey-hex>`; 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 |
|---|---|---| |---|---|---|
| ANNOUNCE | client → server | not implemented | | `:quic` v1 + HTTP/3 + WebTransport | ✅ done | RFC 9000 + 9220; auditor passes |
| ANNOUNCE_OK / ANNOUNCE_ERROR | server → client | decode + match-by-namespace | | moq-lite Lite-03 codec (announce / subscribe / group) | ✅ done | `moq/lite/`, full unit suite |
| ANNOUNCE_CANCEL | server → client | decode + signal publisher to stop | | moq-lite session (listener + publisher) | ✅ done | `MoqLiteSession.client(...)` |
| UNANNOUNCE | client → server | encode | | `WebTransportSession.incomingBidiStreams` + `openUniStream` | ✅ done | needed for publisher |
| SUBSCRIBE | server → client (we're publisher) | accept + map to our track sink | | IETF `draft-ietf-moq-transport-17` codec | ✅ done | reference impl, no production caller |
| SUBSCRIBE_OK / SUBSCRIBE_ERROR | client → server | encode |
| SUBSCRIBE_DONE | client → server | encode on track end |
| OBJECT_DATAGRAM (publish-side) | client → server | encode + emit |
API we need on `MoqSession`: ### `:nestsClient`
```kotlin | Surface | Status | Notes |
suspend fun announce(
namespace: TrackNamespace,
parameters: List<TrackParameter> = 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<NestsSpeakerState>
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 |
|---|---|---| |---|---|---|
| M1 Listener wire-up | 5 | 5 | | HTTP `/auth` JWT mint (NIP-98 → ES256 JWT) | ✅ done | `OkHttpNestsClient.mintToken` |
| M2 Multi-speaker | 3 | 8 | | `connectNestsListener``MoqLiteNestsListener` | ✅ done | path = `/<namespace>?jwt=<token>` |
| M3 Foreground listener | 2 | 10 | | `connectNestsSpeaker``MoqLiteNestsSpeaker` | ✅ done | publishes `audio/data` track |
| M4 Real-server interop | 3 | 13 | | `AudioRecordCapture` + `MediaCodecOpusEncoder` | ✅ done | Android actuals |
| M5 MoQ publisher | 5 | 18 | | `MediaCodecOpusDecoder` + `AudioTrackPlayer` | ✅ done | Android actuals |
| M6 Capture + encode | 3 | 21 | | `AudioRoomMoqLiteBroadcaster` + `AudioRoomPlayer` | ✅ done | encode / decode pumps |
| M7 NestsSpeaker | 2 | 23 | | Interop harness (Docker compose nostrnests stack) | ✅ done | `NostrNestsHarness`, 4 test classes |
| M8 Polish | 4 | 27 |
| M9 Foreground speaker | 2 | 29 |
**6 weeks** to ship full audio rooms (listener + speaker + Android polish). ### Amethyst (Android UI)
**2 weeks** to ship listener-only (M1+M3+M4) which is the 95% case for
audience members.
## 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 ### Pending
protocol-comparison pass (likely a draft-version mismatch or a small
framing bug). Up to 1 wk of `:quic` adjustment, otherwise we ship behind | Item | Phase | Effort |
a feature flag and chase interop async. |---|---|---|
- **MediaCodec Opus is missing on a target device.** Android 10+ ships the | Reconnect / retry on listener Failed | M8 | 1 d |
decoder; for older devices we'd need a software Opus, which is out of | Per-speaker level meters (RMS from decoder output) | M8 | 1 d |
scope. | 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 ## 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` - 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` - Audio-rooms NIP draft (needs refresh after the moq-lite findings):
- Original (frozen) QUIC plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md` `docs/plans/2026-04-22-nip-audio-rooms-draft.md`
- Existing listener entry point: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt` - Listener entry: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt`
- App-side audio-room screen: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/` - 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/`
+23 -10
View File
@@ -1,9 +1,11 @@
# Plan: bridge the moq-lite protocol gap # Plan: bridge the moq-lite protocol gap
**Status:** **listener AND speaker sides done** (phases 5a → 5d, commit **Status:** **DONE**listener + speaker on moq-lite Lite-03 against
range `fb47a4c``5914e9e` + the speaker phase 5c-speaker landing in the real nostrnests stack. Phases 5a → 5d landed (`fb47a4c`
this PR). Default `:nestsClient:jvmTest` suite passes; downstream `71cf99d`); follow-up cleanup + harness fixes shipped through `015b0d7`.
`:commons` and `:amethyst` compile clean. 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 14). **Origin:** discovered while writing the nostrnests interop test suite (phases 14).
@@ -299,9 +301,20 @@ host runs them with `-DnestsInterop=true`.
## When picking up ## When picking up
- Read `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/` This doc captures the wire spec used during implementation. For the
for the JS reference. shipped surface, start from:
- Read `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,
path}.rs` for the canonical Rust implementation. - `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/`
- The nests-side `claims.put = [pubkey]` rule is in — `MoqLiteCodec`, `MoqLiteSession`, `MoqLitePath`, `MoqLiteFraming`.
`moq-auth/src/index.ts:160-166`. - `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.
@@ -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, "<relay>", "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","<unix>"]`. `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 <u>", "mode <tile|cover>"]`.
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: [<pubkey>]` (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`