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.
11 KiB
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— extendbuild(...)(and add awithFlagsoverload 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?andonstage(): Boolean?mirroring the existinghandRaised()/muted().commons/.../viewmodels/AudioRoomViewModel.kt— addpublishingNow: BooleanandonStageNow: BooleantoRoomUiState; thread them throughsetMuted/setOnStage/startBroadcastso the heartbeat picks the right values.amethyst/.../audiorooms/room/AudioRoomActivityContent.kt— thepublishPresence(...)helper + the LaunchedEffect that drives it: includepublishing(true whileBroadcastUiState.Broadcastingis active) andonstage(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:Equality + hash bydata class RoomPresence( val pubkey: String, val handRaised: Boolean, val muted: Boolean?, val publishing: Boolean, val onstage: Boolean, val updatedAt: Long, )pubkeyso aMap<String, RoomPresence>swaps cleanly on update.commons/.../viewmodels/AudioRoomViewModel.kt— newpresences: StateFlow<Map<String, RoomPresence>>populated by a subscription to LocalCache filtered bykinds=[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 aRoomPresenceFilterthat REQs the above and feedsLocalCache.
Listener counter (#8)
amethyst/.../audiorooms/room/AudioRoomFullScreen.kt— smallText(stringRes(R.string.audio_room_listener_count, presences.size))badge near the room title. Strings: addaudio_room_listener_countwith%1$dplaceholder.
Tests
quartz/.../MeetingRoomPresenceEventTest.kt— round-trip the newpublishing+onstagetags.commons/.../AudioRoomViewModelTest.kt—presencesmap updates on a fakeLocalCacheadd; 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 filterkinds=[1311], #a=[roomATag], ordered bycreated_atascending.send(text: String)builds + signs viaLiveActivitiesChatMessageEvent.message(text, roomATag) {...}and broadcasts viaaccount.signAndComputeBroadcast(template)(mirror ofChannelNewMessageViewModel).amethyst/.../audiorooms/room/AudioRoomChatPanel.kt(NEW) — Compose list with auto-scroll-to-bottom; per-message row with avatar + display name + content; bottom row withOutlinedTextField+ send button.amethyst/.../audiorooms/datasource/AudioRoomChatSubAssembler.kt(NEW) — REQ forkinds=[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—messagesupdates on fake LocalCache add;sendproduces 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 fromkinds=[7,9735], #a=[roomATag].amethyst/.../audiorooms/room/RoomReactionPickerSheet.kt(NEW) — small bottom sheet with default emojis +EmojiPackEventfavourites.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 forkinds=[7,9735], #a=[roomATag].- Reactions button in
AudioRoomFullScreen.ktnear 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 againstNestsUI-v2/hooks/useRoomReactions.tsexact tag emission and whether theetag 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 ofCreateAudioRoomSheetpre-populated from the existingMeetingSpaceEvent. On submit, re-publish kind-30312 with the samedtag.EditAudioRoomViewModel.kt(NEW) — mirror ofCreateAudioRoomViewModelwith 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 whenaccount.userProfile() == event.pubKey; options "Edit room" / "Close room".
Scheduled rooms (#7)
amethyst/.../audiorooms/create/CreateAudioRoomSheet.kt— add a toggle "Start now / Schedule"; show aDatePicker + TimePickerwhen scheduled. ViewModel already has a status field; emitSTATUS.PLANNED+["starts", <unix>]via the existingTagArrayBuilderExt.starts(...)helper.MeetingSpaceEvent's tag DSL hasstartsalready; 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. AddisAdmin(...)helper alongsideisHost(...).quartz/.../meetingSpaces/MeetingSpaceEvent.kt— verifyparticipants()returns ALL roles (host,admin,speaker,participant); addadmins()/speakers()filters if missing.
Commons / VM
commons/.../viewmodels/AudioRoomViewModel.kt:- Replace single-host gating with role check:
isLocalUserSpeaker = roles.contains(localPubkey)whereroles = host ∪ admin ∪ speaker. - Gate
startBroadcast()onisLocalUserSpeaker.
- Replace single-host gating with role check:
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 alreadyspeaker|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 inEventFactory.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 ahost|admin, firedisconnect().
- Subscribe to
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 →connectionflips toIdlewithin 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)
- presence-tags + listener-counter (Step 1 + #8)
- live-chat (Step 2)
- reactions (Step 3)
- edit + close + scheduled (Step 4)
- role-parsing + promote / demote + hand-raise queue (Step 5)
- kick admin command (Step 6)
Tier 2 and beyond live in sibling docs in this folder.