docs(audio-rooms): coding plans for Tier 1-4, one file per tier

The earlier integration audit identified the gaps; this is the
how-to-build-them. Split across four files plus an index so each
review / commit stays small:

  - 2026-04-26-tier-plans-index.md — top-level pointer + sequence
    dependencies + what's deliberately out of scope.

  - 2026-04-26-tier1-coding-plan.md — listener counter, presence
    aggregation, augmented kind-10312 tags (publishing/onstage),
    live chat (kind 1311), reactions (kind 7 / 9735), edit + close
    + scheduled rooms, role parsing + promote/demote, hand-raise
    queue, kick (kind 4312). Concrete file-level wiring for each
    step + suggested commit order (six independent PRs).

  - 2026-04-26-tier2-coding-plan.md — participant grid,
    per-avatar context menu (follow / mute / zap / promote / kick),
    zap entry points (room + speaker), share-via-naddr.

  - 2026-04-26-tier3-coding-plan.md — room theming PARSER ONLY
    (graceful fallback for themed rooms; full theming behind a
    later phase), background-audio + wake-lock audit checklist.

  - 2026-04-26-tier4-coding-plan.md — moq-auth token re-mint on
    long sessions and moq-lite Connection.Reload-equivalent
    reconnect with backoff. Step 1 is subsumed by Step 2 once the
    reconnect path is in.

No code changes — pure docs. Each plan names exact file paths,
new types, reused helpers, strings, tests, and call-out risks so
an implementer (or follow-up agent) can pick up Step N without
re-deriving the surrounding context.
This commit is contained in:
Claude
2026-04-26 19:52:07 +00:00
parent 74d5e77a83
commit e71a2b26ef
5 changed files with 801 additions and 0 deletions
@@ -0,0 +1,39 @@
# Tier 1-4 coding plans — index
Concrete, file-level coding plans for shipping the punchlist captured
in `2026-04-26-nostrnests-integration-audit.md`. Split across four
files (one per tier) so each commit / PR stays small and reviewable.
| Tier | Doc | Scope |
|---|---|---|
| 1 | [`2026-04-26-tier1-coding-plan.md`](2026-04-26-tier1-coding-plan.md) | Listener-counter, presence aggregation, augmented presence tags, live chat (kind 1311), reactions (kind 7 / 9735), edit + close + scheduled rooms, role parsing + promotion, hand-raise queue, kick (kind 4312). |
| 2 | [`2026-04-26-tier2-coding-plan.md`](2026-04-26-tier2-coding-plan.md) | Participant grid, per-avatar context menu (follow / mute / zap / promote / kick), zap entry points, share-via-naddr. |
| 3 | [`2026-04-26-tier3-coding-plan.md`](2026-04-26-tier3-coding-plan.md) | Room theming PARSER (graceful fallback), background-audio + wake-lock audit. |
| 4 | [`2026-04-26-tier4-coding-plan.md`](2026-04-26-tier4-coding-plan.md) | moq-auth token re-mint on long sessions, moq-lite Connection.Reload-style reconnect with backoff. |
## Sequence dependencies
- **Tier 1 Step 1 (presence aggregation)** unblocks several later
items: listener counter (T1-S1.x), hand-raise queue (T1-S5),
participant grid (T2-S1).
- **Tier 1 Step 5 (role parsing + promotion)** is required before
the context-menu's role / kick rows in Tier 2 Step 2.
- **Tier 1 Step 6 (kick / kind 4312)** depends on Step 5 wiring.
- **Tier 2 Steps 1-4** can ship independently once Tier 1 is in.
- **Tier 3** is independent — can ship in parallel with anything.
- **Tier 4 Step 1** is subsumed by Tier 4 Step 2 once the
reconnect-with-backoff path exists.
## What each plan deliberately leaves OUT
- API.md endpoints — confirmed dead (LiveKit-era). Not in any plan.
- NIP-71, hashtags, spotlight, `ends` — confirmed not used by
nostrnests. Not in any plan.
- Multi-track / video, fetch / replay, bitrate probes — confirmed
unused. Not in any plan.
- WebSocket fallback — browser-only fallback, irrelevant for
Android.
- Kind 36767 (Ditto themes) and kind 16767 (per-user profile
themes) — out of scope for the basic theming sliver in Tier 3.
For the underlying gap rationale see the audit doc.
@@ -0,0 +1,309 @@
# Tier 1 — coding plan (presence aggregation, chat, reactions, roles, kick, edit/close, scheduled, listener counter)
Concrete file-level plan for shipping Tier 1 of
`2026-04-26-nostrnests-integration-audit.md`. Sequence is **strict**
later items consume types added by earlier ones.
## Step 1 — Listener-side presence aggregation (#8) + augmented presence emit (#10)
Unblocks the participant grid (Tier 2 #9), the hand-raise queue (#4),
and the listener counter.
### New / changed
- `quartz/.../nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt`
— extend `build(...)` (and add a `withFlags` overload if cleaner)
to emit `["publishing", "0|1"]` and `["onstage", "0|1"]` alongside
the existing `["hand", ...]` + `["muted", ...]` tags.
- `quartz/.../nip53LiveActivities/presence/MeetingRoomPresenceEvent.kt`
— add accessor parsers: `publishing(): Boolean?` and
`onstage(): Boolean?` mirroring the existing `handRaised()` /
`muted()`.
- `commons/.../viewmodels/AudioRoomViewModel.kt` — add
`publishingNow: Boolean` and `onStageNow: Boolean` to
`RoomUiState`; thread them through `setMuted` / `setOnStage` /
`startBroadcast` so the heartbeat picks the right values.
- `amethyst/.../audiorooms/room/AudioRoomActivityContent.kt`
the `publishPresence(...)` helper + the LaunchedEffect that drives
it: include `publishing` (true while `BroadcastUiState.Broadcasting`
is active) and `onstage` (default true; flipped false on a
"leave the stage" tap that #9 will add).
### New listener-side aggregation
- `commons/.../viewmodels/RoomPresenceState.kt` (NEW) — pure data
class:
```kotlin
data class RoomPresence(
val pubkey: String,
val handRaised: Boolean,
val muted: Boolean?,
val publishing: Boolean,
val onstage: Boolean,
val updatedAt: Long,
)
```
Equality + hash by `pubkey` so a `Map<String, RoomPresence>` swaps
cleanly on update.
- `commons/.../viewmodels/AudioRoomViewModel.kt` — new
`presences: StateFlow<Map<String, RoomPresence>>` populated by a
subscription to LocalCache filtered by
`kinds=[10312], #a=[roomATag], since=now-5min`. Updates dedupe by
pubkey, keeping the most recent. Records older than 5 min get
garbage-collected on every emission.
- `amethyst/.../audiorooms/datasource/AudioRoomsFilterAssembler.kt`
(or sibling — confirm which assembler the room screen uses) — add a
`RoomPresenceFilter` that REQs the above and feeds `LocalCache`.
### Listener counter (#8)
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — small
`Text(stringRes(R.string.audio_room_listener_count, presences.size))`
badge near the room title. Strings: add `audio_room_listener_count`
with `%1$d` placeholder.
### Tests
- `quartz/.../MeetingRoomPresenceEventTest.kt` — round-trip the new
`publishing` + `onstage` tags.
- `commons/.../AudioRoomViewModelTest.kt` — `presences` map updates
on a fake `LocalCache` add; pubkey dedupes on a re-emit; entries
older than 5 min get evicted.
### Risk / open questions
- The 5-min eviction can race with a peer's heartbeat being late.
Use a 6-min window in code, 5 min in the user-visible "active"
count? Document in the ViewModel.
---
## Step 2 — Live chat panel (#1)
Depends only on Step 1 being merged (so the chat sub uses the same
`a`-tag assembler pattern).
### Reuse
- `quartz/.../nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt`
— already present; covers parse + build + reply.
- `commons/.../viewmodels/AudioRoomViewModel.kt` — model already has
the room's address; pull it.
### New
- `commons/.../viewmodels/RoomChatViewModel.kt` (NEW) —
`messages: StateFlow<List<LiveActivitiesChatMessageEvent>>`
sourced from LocalCache filter
`kinds=[1311], #a=[roomATag]`, ordered by `created_at` ascending.
`send(text: String)` builds + signs via
`LiveActivitiesChatMessageEvent.message(text, roomATag) {...}`
and broadcasts via `account.signAndComputeBroadcast(template)`
(mirror of `ChannelNewMessageViewModel`).
- `amethyst/.../audiorooms/room/AudioRoomChatPanel.kt` (NEW) — Compose
list with auto-scroll-to-bottom; per-message row with avatar +
display name + content; bottom row with `OutlinedTextField` +
send button.
- `amethyst/.../audiorooms/datasource/AudioRoomChatSubAssembler.kt`
(NEW) — REQ for `kinds=[1311], #a=[roomATag]` while the room
screen is composed.
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — add a
collapsible / bottom-sheet chat panel; on phone, slide-up over the
audience grid.
### Strings
`audio_room_chat_send`, `audio_room_chat_placeholder`,
`audio_room_chat_empty`.
### Tests
- `commons/.../RoomChatViewModelTest.kt` — `messages` updates on
fake LocalCache add; `send` produces a kind-1311 with the
expected `["a", roomATag]` tag.
---
## Step 3 — Reactions (#2)
### Reuse
- `quartz/.../nip25Reactions/ReactionEvent.kt` — present.
- `AccountViewModel.reactToOrDelete(note, reaction)` — already does
the broadcast.
### New
- `commons/.../viewmodels/RoomReactionsViewModel.kt` (NEW) —
`recentReactions: StateFlow<Map<String, List<RoomReaction>>>`
keyed by target pubkey, dropping entries older than 30 s.
Sourced from `kinds=[7,9735], #a=[roomATag]`.
- `amethyst/.../audiorooms/room/RoomReactionPickerSheet.kt` (NEW) —
small bottom sheet with default emojis + `EmojiPackEvent` favourites.
- `amethyst/.../audiorooms/room/SpeakerReactionOverlay.kt` (NEW) —
per-avatar overlay rendering the last 30 s of reactions as
floating-up icons.
- `amethyst/.../audiorooms/datasource/AudioRoomReactionsSubAssembler.kt`
(NEW) — REQ for `kinds=[7,9735], #a=[roomATag]`.
- Reactions button in `AudioRoomFullScreen.kt` near the mic toggle.
### Tests
- `commons/.../RoomReactionsViewModelTest.kt` — 30-s window sliding;
per-pubkey grouping.
### Risk
- The kind-7 `["a", roomATag]` shape isn't standard NIP-25 (which
reacts to a single event). Confirm against
`NestsUI-v2/hooks/useRoomReactions.ts` exact tag emission and
whether the `e` tag also points at the room's id.
---
## Step 4 — Edit room / close room (#6) + Scheduled rooms (#7)
Pure UI work + reuse `account.signAndComputeBroadcast`.
### Changed
- `amethyst/.../audiorooms/room/EditAudioRoomSheet.kt` (NEW) —
copy of `CreateAudioRoomSheet` pre-populated from the existing
`MeetingSpaceEvent`. On submit, re-publish kind-30312 with the
same `d` tag.
- `EditAudioRoomViewModel.kt` (NEW) — mirror of
`CreateAudioRoomViewModel` with two extra paths:
- `closeRoom()` → re-publish with `["status", "closed"]`
- `endRoom()` → re-publish with `["status", "ended"]` if
nostrnests treats that as the canonical "kill"
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — overflow
menu visible only when `account.userProfile() == event.pubKey`;
options "Edit room" / "Close room".
### Scheduled rooms (#7)
- `amethyst/.../audiorooms/create/CreateAudioRoomSheet.kt` — add a
toggle "Start now / Schedule"; show a `DatePicker + TimePicker`
when scheduled. ViewModel already has a status field; emit
`STATUS.PLANNED` + `["starts", <unix>]` via the existing
`TagArrayBuilderExt.starts(...)` helper.
- `MeetingSpaceEvent`'s tag DSL has `starts` already; verify it
(`quartz/.../meetingSpaces/TagArrayBuilderExt.kt`).
### Strings
`audio_room_edit_title`, `audio_room_close_action`,
`audio_room_create_schedule_toggle`, `audio_room_create_when`.
### Risk
- Re-publishing a kind-30312 with a smaller `p`-tag set (e.g. host
removed someone) requires the FULL list of participants to be
rebuilt — don't lose anyone who'd already been promoted. The
ViewModel needs to read the current participant list and only
diff the one row the user touched.
---
## Step 5 — Speaker / admin role parsing + promotion (#3) + hand-raise queue (#4)
Depends on Step 1 (presence aggregation) for the hand-raised list.
### Quartz
- `quartz/.../nip53LiveActivities/streaming/tags/ParticipantTag.kt` —
already parses the role byte. Add `isAdmin(...)` helper alongside
`isHost(...)`.
- `quartz/.../meetingSpaces/MeetingSpaceEvent.kt` — verify
`participants()` returns ALL roles (`host`, `admin`, `speaker`,
`participant`); add `admins()` / `speakers()` filters if missing.
### Commons / VM
- `commons/.../viewmodels/AudioRoomViewModel.kt`:
- Replace single-host gating with role check:
`isLocalUserSpeaker = roles.contains(localPubkey)` where
`roles = host admin speaker`.
- Gate `startBroadcast()` on `isLocalUserSpeaker`.
### Amethyst
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — host /
admin overflow on each participant avatar:
- "Promote to speaker" / "Demote to listener"
- Re-publishes kind-30312 with the target's `p`-tag role updated.
- `amethyst/.../audiorooms/room/HandRaiseQueueSection.kt` (NEW) —
list of pubkeys whose latest presence has `["hand", "1"]` and who
aren't already `speaker|admin|host`. Each row has an "Approve"
button that promotes them.
### Strings
`audio_room_promote_speaker`, `audio_room_demote_listener`,
`audio_room_raised_hands_section`,
`audio_room_approve_speaker`.
### Tests
- `commons/.../AudioRoomViewModelTest.kt` — role gating: a plain
listener can't startBroadcast; promotion via the host's emit makes
the local user eligible.
---
## Step 6 — Kick (#5)
### Quartz
- `quartz/.../experimental/audiorooms/admin/AdminCommandEvent.kt`
(NEW) — kind 4312, ephemeral. Builder takes
`(roomATag, target, action)`. `parse(...)` returns
`(targetPubkey, action)`. Register in `EventFactory.kt`.
### Commons
- `commons/.../viewmodels/AudioRoomViewModel.kt`:
- Subscribe to `kinds=[4312], #a=[roomATag], #p=[localPubkey],
since=now-60s`. On a match where the event is signed by a
`host|admin`, fire `disconnect()`.
### Amethyst
- Kick action on the role overflow (Step 5) — host/admin only.
- Tear down: confirm `disconnect()` fully closes the WT session +
clears the foreground service.
### Tests
- `quartz/.../AdminCommandEventTest.kt` — round-trip + reject when
signer isn't a host.
- `commons/.../AudioRoomViewModelTest.kt` — receive a valid kick →
`connection` flips to `Idle` within the test scope.
---
## Strings batch for Tier 1
Single PR can collect these into `strings.xml` to avoid back-and-forth:
```
audio_room_listener_count, audio_room_chat_send,
audio_room_chat_placeholder, audio_room_chat_empty,
audio_room_reactions_button, audio_room_edit_title,
audio_room_close_action, audio_room_create_schedule_toggle,
audio_room_create_when, audio_room_promote_speaker,
audio_room_demote_listener, audio_room_raised_hands_section,
audio_room_approve_speaker, audio_room_kick_speaker,
audio_room_kicked_toast
```
## Suggested commit order (one PR each, mergeable independently)
1. presence-tags + listener-counter (Step 1 + #8)
2. live-chat (Step 2)
3. reactions (Step 3)
4. edit + close + scheduled (Step 4)
5. role-parsing + promote / demote + hand-raise queue (Step 5)
6. kick admin command (Step 6)
Tier 2 and beyond live in sibling docs in this folder.
@@ -0,0 +1,170 @@
# Tier 2 — coding plan (participant grid, per-avatar context menu, zap, naddr share)
Concrete file-level plan for shipping Tier 2 of
`2026-04-26-nostrnests-integration-audit.md`. Assumes Tier 1 has
landed (presence aggregation, role parsing, edit-room flow).
## Step 1 — Participant grid (#9)
The flagship UI piece. Renders three groups:
1. **On-stage** — pubkeys in the kind-30312's `p` tags with role
`host`, `admin`, or `speaker`, minus those whose latest presence
has `["onstage", "0"]`.
2. **Currently broadcasting** — pubkeys with active moq-lite
announces visible on `MoqLiteSession.announce(prefix=room).updates`.
3. **Audience** — every pubkey from kind-10312 presence in the last
5 min that isn't on stage.
### Reuse
- `commons/.../viewmodels/AudioRoomViewModel.presences` (Tier 1
Step 1) — gives us {pubkey → flags + lastSeen}.
- `MoqLiteSession.announce(prefix)` — already returns a flow of
`MoqLiteAnnounce` updates; need to expose it on `MoqLiteNestsListener`
via a `liveBroadcasters: StateFlow<Set<String>>`.
### New
- `commons/.../viewmodels/AudioRoomViewModel.kt`:
- `liveBroadcasters: StateFlow<Set<String>>` populated by an
announce-please subscription against the room's namespace
prefix. Tracks only `Active` suffixes (drop on `Ended`).
- `participantGrid: StateFlow<ParticipantGrid>` derived from
`event.participants() + presences + liveBroadcasters`.
- `commons/.../viewmodels/ParticipantGrid.kt` (NEW) — pure data:
```kotlin
data class ParticipantGrid(
val onStage: List<RoomMember>,
val audience: List<RoomMember>,
)
data class RoomMember(
val pubkey: String,
val role: ROLE?, // null for plain listeners
val handRaised: Boolean,
val muted: Boolean?,
val publishing: Boolean,
val lastSeenSeconds: Int?,
)
```
- `nestsClient/.../MoqLiteNestsListener.kt` — expose
`discoverPublishers(prefix: String): Flow<MoqLiteAnnounce>`
(thin wrapper that delegates to the underlying
`MoqLiteSession.announce(prefix).updates`).
- `amethyst/.../audiorooms/room/ParticipantsGrid.kt` (NEW) — Compose
`LazyVerticalGrid` with section headers ("On stage", "Audience").
Each cell is the avatar + small status icons (mic on/off, raised
hand, broadcasting indicator).
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — replace
the current `onStage` / `audience` blocks with `ParticipantsGrid`.
### Risk / open
- Decide what to render when a `kind-30312` `p` tag has no
presence (member never joined): show greyed-out? hide entirely?
nostrnests greys out — match for parity.
---
## Step 2 — Per-participant context menu (#11)
A bottom sheet that opens on avatar tap.
### Reuse
- `accountViewModel.toggleFollow(user)` and similar helpers exist;
audit `AccountViewModel.kt` for the public surface.
- `accountViewModel.zap(...)` for zaps (next step).
- `MutedUsersScreen` patterns for mute / unmute.
### New
- `amethyst/.../audiorooms/room/ParticipantContextSheet.kt` (NEW)
— `ModalBottomSheet` with rows:
- **View profile** → `nav.nav(Route.User(pubkey))`
- **Follow / Unfollow** → `accountViewModel.toggleFollow`
- **Mute** → mute-list edit
- **Zap** → opens the existing zap dialog (Step 3 below)
- **Promote to speaker / Demote** → only when local user is
host/admin (Tier 1 Step 5 surface)
- **Kick** → only when local user is host/admin
- Call site: `ParticipantsGrid` cell `onLongClick` (or tap, for
parity with nostrnests web behaviour) opens the sheet keyed on
the `pubkey`.
### Strings
`audio_room_participant_view_profile`,
`audio_room_participant_follow`, `audio_room_participant_unfollow`,
`audio_room_participant_mute`, `audio_room_participant_zap`,
(role + kick strings already added in Tier 1).
---
## Step 3 — Zap support inside the room (#12)
Mostly a matter of plumbing the existing zap dialog.
### Reuse
- Amethyst's existing zap flow — find the `ZapDialog` / `ZapBottomSheet`
composable and its `accountViewModel.zap` plumbing.
- Reactions sub from Tier 1 Step 3 — already pulls
`kinds=[7,9735], #a=[roomATag]`, so a paid zap appears in the
reaction overlay automatically.
### New
- Two zap entry points:
1. From the context sheet (Step 2) — zap a single participant.
2. From a "Zap room" button in `AudioRoomFullScreen.kt`'s
overflow — zap the kind-30312 host pubkey, tag the room
address.
- A small `RoomZapAdapter.kt` (NEW) that builds the zap request
pointing at either the room (NIP-57 `["a", roomATag]`) or a
speaker (NIP-57 `["p", pubkey]`).
### Risk
- The existing zap dialog likely takes a `Note`. The room IS a
`Note` (`AddressableNote` over the kind-30312); confirm the
dialog accepts it. If not, add an overload accepting
`(authorPubkey, optionalRoomATag)`.
---
## Step 4 — Share via naddr (#13)
### Reuse
- `quartz/.../nip19Bech32/NAddress.kt` — has `create(...)`.
- Amethyst likely has a generic `ShareDialog` for `naddr` /
`nevent` share — find it, reuse.
### New
- `amethyst/.../audiorooms/room/AudioRoomFullScreen.kt` — overflow
menu "Share room":
- Build `NAddress.create(KIND, hostPubkey, dTag, relays)` from
the room event.
- Open `ShareDialog` with the resulting `naddr1...` + a
pre-filled `nostr:naddr1...` clipboard option + a
"Share to Nostr" button that opens the existing
`ShortNotePostScreen` pre-loaded with the naddr URI.
### Strings
`audio_room_share_action`, `audio_room_share_text` (template like
`"Join {room name} {nostr:naddr1...}"`).
---
## Suggested commit order
1. participant-grid (Step 1) — the foundation; everything else hangs
off it.
2. context-sheet skeleton (Step 2 without role / kick rows; reuses
Tier 1 Step 5 wiring once it's in).
3. zap (Step 3) — plumbing-only.
4. naddr share (Step 4) — pure UI.
Tiers 3 and 4 live in sibling docs.
@@ -0,0 +1,125 @@
# Tier 3 — coding plan (room theming, background-audio audit)
Tier 3 of `2026-04-26-nostrnests-integration-audit.md`. These are
larger or lower-priority items; do them after Tier 1 and Tier 2 ship.
## Step 1 — Room theming PARSER ONLY (#14, partial)
The minimum-viable slice. Goal: **don't render themed rooms badly.**
We parse the theme tags and either honour them with Compose color
overrides or ignore them with a graceful fallback (current visual).
Skip the kind 36767 / 16767 Ditto-theme refs and the per-user
profile theme — those are full features deferable to a later phase.
### Quartz
- `quartz/.../nip53LiveActivities/meetingSpaces/tags/ColorTag.kt`
(NEW) — parser for `["c", hex, "background"|"text"|"primary"]`.
`assemble(hex, target)` builder.
- `quartz/.../meetingSpaces/tags/FontTag.kt` (NEW) —
parser for `["f", family, optionalUrl]`. Builder.
- `quartz/.../meetingSpaces/tags/BackgroundTag.kt` (NEW) —
parser for `["bg", "url <u>", "mode tile|cover"]`. Builder.
- `quartz/.../meetingSpaces/MeetingSpaceEvent.kt` — accessors:
`colors(): List<ColorTag>`, `font(): FontTag?`,
`background(): BackgroundTag?`.
- `quartz/.../meetingSpaces/TagArrayBuilderExt.kt``colors`,
`font`, `background` DSL functions.
### Commons / Amethyst
- `commons/.../viewmodels/AudioRoomViewModel.kt` — expose
`roomTheme: RoomTheme?` derived from the event.
- `commons/.../viewmodels/RoomTheme.kt` (NEW):
```kotlin
data class RoomTheme(
val background: Color?,
val text: Color?,
val primary: Color?,
val backgroundImageUrl: String?,
val backgroundMode: BgMode = BgMode.COVER,
)
enum class BgMode { TILE, COVER }
```
- `amethyst/.../audiorooms/room/AudioRoomThemedScope.kt` (NEW) —
Composable wrapper that takes a `RoomTheme?` and overrides
Material3 `colorScheme` for its content. If null → no override.
Wrap `AudioRoomFullScreen` body in this.
- Background image: render via Coil `AsyncImage` behind everything,
honouring `backgroundMode` (cover via `ContentScale.Crop`, tile
via custom `Modifier.repeatedBackground`).
### Skip / defer
- Font loading (`["f", family, url]`) — would need a
`FontFamily` loader. Not user-blocking for parity.
- Kind 36767 (Ditto themes) and kind 16767 (per-user profile
themes). Mark as out-of-scope for now.
### Strings
None — pure visual.
### Tests
- `quartz/.../ColorTagTest.kt` — round-trip + invalid hex / target.
- `commons/.../AudioRoomViewModelTest.kt` — `roomTheme` populates
from a fake `MeetingSpaceEvent` with `c` / `bg` tags.
### Risk
- Some rooms use unusual hex shapes (`#abc`, named colors). Keep
the parser strict (`^#?[0-9a-fA-F]{6}$`) and fall back on parse
failure rather than crashing.
---
## Step 2 — Background audio + wake-lock audit (#15)
### Audit checklist
For each item, look in the named file and either confirm the
behaviour or open a follow-up bullet:
1. **`PARTIAL_WAKE_LOCK`** during a broadcast.
File: `amethyst/src/main/java/com/vitorpamplona/amethyst/service/audiorooms/AudioRoomForegroundService.kt`.
Check: is `PowerManager.newWakeLock(PARTIAL_WAKE_LOCK, "...")`
acquired in `promoteToMicrophone(...)` and released in
`stop(...)`? If not, add it.
2. **Foreground service type `microphone`** for Android 14+.
Same file. Confirm the manifest declares
`android:foregroundServiceType="microphone"` and the service
call uses `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_MICROPHONE)`.
3. **Listener-only foreground type** when not broadcasting.
`AudioRoomForegroundService.startListening(context)` should use
`FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` (NOT microphone — Play
Store rejects the elevated permission for listen-only users).
4. **Audio focus**. AudioTrack alone doesn't request focus; verify
`AudioManager.requestAudioFocus(AudioFocusRequest.Builder(...))`
is called in `AudioTrackPlayer.start()` and abandoned in
`stop()`. If not, add it (so a phone call ducks the room).
5. **PIP keep-alive**. Confirm `AudioRoomActivity` survives the
transition to PIP without dropping the foreground service —
should already be the case (commit history shows this was
audited previously).
### Output
A short audit notes file under `nestsClient/plans/` if any item
turns up missing — otherwise mark this step done.
---
## Suggested commit order
1. theme parser (Quartz: tags + accessors + tests) — small, safe
2. theme renderer (Compose: themed scope + background image)
3. background-audio audit notes (and any fixes that surface)
## Out of scope for Tier 3
- Kind 36767 (Ditto theme reference) and kind 16767 (per-user
profile theme) — these are full sub-features behind the basic
theming. Defer to a separate phase if needed.
- Per-user font loading — defer.
@@ -0,0 +1,158 @@
# Tier 4 — coding plan (token refresh, moq-lite reconnect backoff)
Tier 4 of `2026-04-26-nostrnests-integration-audit.md`. Pure
infrastructure hardening — no user-visible UX changes. Covers the
items the audit flagged as "MAYBE GAP" or "worth confirming".
## Step 1 — Token re-mint before expiry on long sessions (#16)
moq-auth tokens live 600 s and there's no refresh endpoint. A
listener / speaker that stays in a room past the 10-minute mark
needs to mint a fresh JWT and reopen the WT session — otherwise the
moq-rs relay starts rejecting per-track auth checks.
### Audit first
- Walk `MoqLiteSession` + `MoqLiteNestsListener` /
`MoqLiteNestsSpeaker` to confirm the JWT is only consulted at
WT CONNECT time (it is — moq-rs's auth check is per-session, not
per-OBJECT). That means the **only thing that needs re-minting
is when the WT session itself dies and we reconnect** — see Step 2.
- If the user stays in a room for hours without a transport blip,
the relay never re-checks the JWT, so we don't strictly need a
pro-active re-mint. **Confirm against moq-rs**: does the relay
drop the session when its claims `exp` passes? If yes → step 2's
reconnect path naturally re-mints.
### If a pro-active re-mint IS needed
Implement in `MoqLiteNestsListener` / `MoqLiteNestsSpeaker`:
- Parse the JWT's `exp` claim from the response — extend
`OkHttpNestsClient.mintToken` to return `(token, expiresAt: Long)`
instead of just `token`.
- Schedule a coroutine in `connectNests*` that fires
`(expiresAt - now - 60s)` before expiry and calls a private
`reauthorize()` that mints a fresh token and...
- either silently passes it to a (yet-to-exist) moq-lite
"AUTH_REFRESH" message — moq-lite **doesn't have one** today,
- **or** quietly tears down the current WT session + reconnects
(matching the reconnect path in Step 2).
### Open question
Does moq-rs honour a re-handshake on the same session? Almost
certainly not — there's no in-band auth message. So the pro-active
path collapses into "trigger a reconnect ~60 s before `exp`".
### Recommendation
Treat this step as **subsumed by Step 2** — once the reconnect
backoff handles transport drops, an `exp`-driven self-disconnect
is just another trigger. Schedule:
```kotlin
val refreshAt = expiresAt - now - REFRESH_LEAD_MS // 60_000
scope.launch {
delay(refreshAt.coerceAtLeast(0))
triggerReconnect(reason = "JWT expiry")
}
```
in `connectNestsListener` / `connectNestsSpeaker`, where
`triggerReconnect(...)` is the same entry point Step 2 builds.
---
## Step 2 — moq-lite Connection.Reload-equivalent reconnect with backoff (#17)
The JS reference uses
```
delay: { initial: 1000, multiplier: 2, max: 30000 }
```
when the WT session drops. Confirm Amethyst's `MoqLiteSession`
has equivalent backoff before adding it.
### Audit
- `nestsClient/.../moq/lite/MoqLiteSession.kt` — does any flow on
it actually reconnect on transport failure? Or does the session
surface `Closed` and leave it to the caller (the
`NestsListener` / `NestsSpeaker` connectors) to retry?
- `nestsClient/.../NestsConnect.kt`
`connectNestsListener` / `connectNestsSpeaker` walk the
HTTP→WT→moq-lite handshake once and return a Listener/Speaker.
No retry today.
The audit will almost certainly conclude: **no auto-reconnect
exists**. Add it.
### Design
Add at the orchestration layer (`connectNestsListener` /
`connectNestsSpeaker`), not inside `MoqLiteSession`. The session is
"this connection"; reconnect is "open another connection".
- `nestsClient/.../NestsReconnectPolicy.kt` (NEW) — pure data:
```kotlin
data class NestsReconnectPolicy(
val initialDelayMs: Long = 1_000,
val multiplier: Double = 2.0,
val maxDelayMs: Long = 30_000,
val maxAttempts: Int = Int.MAX_VALUE,
)
```
- `nestsClient/.../NestsListener.kt` — extend the state machine:
- new `NestsListenerState.Reconnecting(attempt: Int, delayMs: Long)`
- on the underlying session emitting `Closed`, walk the reconnect
flow (mint fresh JWT → open WT → wrap in MoqLiteSession →
re-issue every active SubscribeHandle).
- `nestsClient/.../NestsSpeaker.kt` — equivalent on the speaker
side. Re-publish the broadcast suffix automatically; transparently
resume capture after the new session opens.
### Test
- New `nestsClient/src/jvmTest/.../moq/lite/MoqLiteReconnectTest.kt`
— drive a `FakeWebTransport` that closes after the first frame,
then assert the listener auto-reconnects within
`initialDelayMs + jitter`.
- Update one of the interop tests (round-trip) to deliberately kill
the WT session mid-broadcast and verify the listener resumes
receiving frames.
### Risk
- Re-issuing every active SubscribeHandle has to preserve the
consumer-side `Flow<MoqObject>` so app code doesn't notice.
Achievable by buffering the handle's flow upstream of the
per-session adapter — likely a `MutableSharedFlow` per handle
that the per-session pump emits into.
---
## Suggested commit order
1. **Audit + notes**: open a small text note documenting whether
the relay drops sessions on JWT `exp` (Step 1 dependency) — a
~2-paragraph file in `nestsClient/plans/`.
2. **`NestsReconnectPolicy` + state machine extension** (Step 2,
listener side first).
3. **Auto-reconnect tests** against `FakeWebTransport`.
4. **Speaker-side reconnect** (Step 2 second half).
5. **JWT-expiry-driven reconnect** (Step 1, plumbing only — uses
the Step 2 entry point).
Each step is independently merge-safe; the listener-only path is
useful even if the speaker reconnect ships later.
## Out of scope for Tier 4
- WebSocket fallback (`useWebSocket: true`) — only matters for
browser clients without WebTransport. Android has WT via QUIC, so
not a gap.
- Multi-track / video / fetch — confirmed not used by nostrnests.
- Bitrate probes — not used by nostrnests.
- Per-IP rate-limit smoothing for `/auth` — already handled at the
user-action level (one mint per join). No need for client-side
throttling.