diff --git a/nestsClient/plans/2026-04-26-tier-plans-index.md b/nestsClient/plans/2026-04-26-tier-plans-index.md new file mode 100644 index 000000000..9a9df9dff --- /dev/null +++ b/nestsClient/plans/2026-04-26-tier-plans-index.md @@ -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. diff --git a/nestsClient/plans/2026-04-26-tier1-coding-plan.md b/nestsClient/plans/2026-04-26-tier1-coding-plan.md new file mode 100644 index 000000000..df158e458 --- /dev/null +++ b/nestsClient/plans/2026-04-26-tier1-coding-plan.md @@ -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` swaps + cleanly on update. +- `commons/.../viewmodels/AudioRoomViewModel.kt` — new + `presences: StateFlow>` 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>` + 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>>` + 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", ]` 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. diff --git a/nestsClient/plans/2026-04-26-tier2-coding-plan.md b/nestsClient/plans/2026-04-26-tier2-coding-plan.md new file mode 100644 index 000000000..7ad9bd5a2 --- /dev/null +++ b/nestsClient/plans/2026-04-26-tier2-coding-plan.md @@ -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>`. + +### New + +- `commons/.../viewmodels/AudioRoomViewModel.kt`: + - `liveBroadcasters: StateFlow>` populated by an + announce-please subscription against the room's namespace + prefix. Tracks only `Active` suffixes (drop on `Ended`). + - `participantGrid: StateFlow` derived from + `event.participants() + presences + liveBroadcasters`. +- `commons/.../viewmodels/ParticipantGrid.kt` (NEW) — pure data: + ```kotlin + data class ParticipantGrid( + val onStage: List, + val audience: List, + ) + 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` + (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. diff --git a/nestsClient/plans/2026-04-26-tier3-coding-plan.md b/nestsClient/plans/2026-04-26-tier3-coding-plan.md new file mode 100644 index 000000000..d670a1fad --- /dev/null +++ b/nestsClient/plans/2026-04-26-tier3-coding-plan.md @@ -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 ", "mode tile|cover"]`. Builder. +- `quartz/.../meetingSpaces/MeetingSpaceEvent.kt` — accessors: + `colors(): List`, `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. diff --git a/nestsClient/plans/2026-04-26-tier4-coding-plan.md b/nestsClient/plans/2026-04-26-tier4-coding-plan.md new file mode 100644 index 000000000..c45b43135 --- /dev/null +++ b/nestsClient/plans/2026-04-26-tier4-coding-plan.md @@ -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` 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.