feat(nests): leave on host-ended room + speaker-reconnect interop test

Two ship-readiness items.

1. Status: CLOSED handler. The NestActivityBody never observed kind-30312
   status flips, so when a host ended the room every other listener and
   speaker stayed connected to the relay until they manually backed out
   or the JWT expired — silent room with stale "you're in" UI. New
   LeaveOnRoomClosed composable in NestRoomLifecycle.kt watches
   event.isLive() and calls onLeave() when the live event flips to
   CLOSED (or hits the 8 h auto-close cutoff in MeetingSpaceEvent.
   checkStatus). Same teardown path as kick — VM.onCleared() releases
   the listener + speaker when the activity finishes.

2. Speaker-reconnect interop test against the real nostrnests stack,
   mirroring NostrNestsReconnectingListenerInteropTest. Two cases:
   - Happy path: wrapper drives a single real session, frames round-trip.
   - Forced JWT refresh (4 s window): orchestrator recycles the
     underlying speaker mid-stream; frames pre- AND post-recycle must
     all land on the same listener-side SubscribeHandle. Validates the
     production 540 s ↔ 600 s JWT-TTL relationship against the real
     moq-rs relay. Gated by -DnestsInterop=true.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
This commit is contained in:
Claude
2026-04-28 07:58:01 +00:00
parent d37eb10b8c
commit 837bde6579
3 changed files with 510 additions and 0 deletions
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.AutoConnectAndTrackSpeakers
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.LeaveOnKick
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.LeaveOnRoomClosed
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.NestForegroundServiceLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.NestPresencePublisher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.NestRoomEventCollectors
@@ -160,6 +161,11 @@ private fun NestActivityBody(
// Kick → leave the activity.
LeaveOnKick(viewModel, onLeave)
// Host ended the room (status: CLOSED) → leave the activity.
// Same teardown path as kick — VM.onCleared() releases the
// listener + speaker when the activity finishes.
LeaveOnRoomClosed(event, onLeave)
val ui by viewModel.uiState.collectAsState()
// System bridges: PIP overlay actions + foreground service.
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.service.nests.NestForegroundService
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import kotlinx.coroutines.flow.SharedFlow
/**
@@ -86,6 +87,39 @@ internal fun LeaveOnKick(
LaunchedEffect(wasKicked) { if (wasKicked) onLeave() }
}
/**
* Bounce out of the room when the host flips the kind-30312
* `status` tag to CLOSED (or when [MeetingSpaceEvent.checkStatus]
* auto-closes a stale event past the 8 h cutoff). The relay
* subscription in [NestRoomFilterAssemblerSubscription] feeds
* LocalCache, `observeNoteEvent` upstream emits the new
* [MeetingSpaceEvent] reference, and this LaunchedEffect's key
* change triggers the leave.
*
* Without this, a host ending the room only takes effect for
* users who back out manually — every other listener / speaker
* stays connected to the relay until either the next JWT
* expiry or `onCleared()` fires when they finally close the
* activity. Users hear silence (no one's publishing) but the
* UI shows them as still "in" the room.
*
* Triggers only when the live event's status is CLOSED; an
* initial event in any other state (PLANNED, OPEN, PRIVATE)
* is a no-op so the room can finish its connect flow first.
*/
@Composable
internal fun LeaveOnRoomClosed(
event: MeetingSpaceEvent,
onLeave: () -> Unit,
) {
// `event.isLive()` returns false on CLOSED. Keying on that
// boolean (rather than the event reference) means the effect
// fires once per live→closed transition, regardless of how
// many other tag changes the host pushes alongside.
val isLive = event.isLive()
LaunchedEffect(isLive) { if (!isLive) onLeave() }
}
/**
* Bridge between the in-Compose VM state and the Activity-level
* Picture-in-Picture controller: pushes mute / connected state UP