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:
@@ -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.
|
||||
Reference in New Issue
Block a user