Files
amethyst/nestsClient/plans/2026-04-26-tier1-coding-plan.md
T
Claude e71a2b26ef 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.
2026-04-26 19:52:07 +00:00

310 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.