feat(audiorooms): host-leave confirmation dialog

When the host taps "Leave" we now show a 3-button confirmation:
  - Close room  → re-publish kind:30312 with status="closed", then leave
  - Just leave  → leave; room stays open until the 8h staleness window
  - Cancel      → dismiss

Audience-side leave flow unchanged. Without this prompt, hosts who tap
Leave silently abandoned the room — listeners saw it as "live" with no
audio until the EGG-01 rule 7 staleness timeout, eight hours later.

Implementation:
  - `closeMeetingSpace(accountViewModel, event)` builds a verbatim
    FormState from the event and reuses
    EditAudioRoomViewModel.buildEditTemplate(... STATUS.CLOSED). Bypasses
    the VM's mutable state so unsaved edits in an open Edit sheet can't
    leak into the close payload.
  - On publish failure we still leave (the user asked to) and surface a
    toast — the room will auto-close at the 8h timeout.

Process death (host backgrounded + Android-killed) is still handled by
the same 8h fallback; no extra wire changes.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 14:24:01 +00:00
parent addb2a4abb
commit 208c90b246
2 changed files with 96 additions and 1 deletions
@@ -33,17 +33,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -64,7 +68,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip19Bech32.toNAddr
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlinx.coroutines.launch
/** /**
* Full-screen layout for [AudioRoomActivity]. Renders title + summary, * Full-screen layout for [AudioRoomActivity]. Renders title + summary,
@@ -98,7 +104,9 @@ internal fun AudioRoomFullScreen(
) { ) {
var showEditSheet by rememberSaveable { mutableStateOf(false) } var showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) }
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
val isHost = accountViewModel.account.signer.pubKey == event.pubKey val isHost = accountViewModel.account.signer.pubKey == event.pubKey
val leaveScope = rememberCoroutineScope()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -256,7 +264,15 @@ internal fun AudioRoomFullScreen(
OutlinedButton(onClick = { showReactionPicker = true }) { OutlinedButton(onClick = { showReactionPicker = true }) {
Text(stringRes(R.string.audio_room_reactions_button)) Text(stringRes(R.string.audio_room_reactions_button))
} }
OutlinedButton(onClick = onLeave) { OutlinedButton(
onClick = {
if (isHost) {
showHostLeaveConfirm = true
} else {
onLeave()
}
},
) {
Text(stringRes(R.string.audio_room_leave)) Text(stringRes(R.string.audio_room_leave))
} }
} }
@@ -269,6 +285,44 @@ internal fun AudioRoomFullScreen(
) )
} }
if (showHostLeaveConfirm) {
AlertDialog(
onDismissRequest = { showHostLeaveConfirm = false },
title = { Text(stringRes(R.string.audio_room_leave_host_title)) },
text = { Text(stringRes(R.string.audio_room_leave_host_body)) },
confirmButton = {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = {
showHostLeaveConfirm = false
leaveScope.launch {
val ok = closeMeetingSpace(accountViewModel, event)
if (!ok) {
accountViewModel.toastManager.toast(
R.string.audio_rooms,
R.string.audio_room_leave_host_close_failed,
)
}
onLeave()
}
},
) {
Text(stringRes(R.string.audio_room_leave_host_close))
}
},
dismissButton = {
TextButton(
onClick = {
showHostLeaveConfirm = false
onLeave()
},
) {
Text(stringRes(R.string.audio_room_leave_host_just_leave))
}
},
)
}
AudioRoomChatPanel( AudioRoomChatPanel(
roomATag = roomATag =
ATag( ATag(
@@ -498,6 +552,42 @@ private fun TalkRow(
} }
} }
/**
* Re-publish the host's [event] with `status="closed"`, preserving every
* other field verbatim (room name, summary, image, service, endpoint,
* participants). Returns true on success.
*
* Reuses [EditAudioRoomViewModel.buildEditTemplate] so the close-on-leave
* path emits the same shape as the explicit Edit → Close room action.
* Decoupled from [EditAudioRoomViewModel] state so the host's unsaved
* edits in the Edit sheet (if any) cannot accidentally leak into the
* close payload.
*/
private suspend fun closeMeetingSpace(
accountViewModel: AccountViewModel,
event: MeetingSpaceEvent,
): Boolean {
val verbatim =
EditAudioRoomViewModel.FormState(
dTag = event.dTag(),
roomName = event.room().orEmpty(),
summary = event.summary().orEmpty(),
imageUrl = event.image().orEmpty(),
endpointUrl = event.endpoint().orEmpty(),
serviceUrl = event.service().orEmpty(),
isPublishing = false,
error = null,
)
return try {
val template =
EditAudioRoomViewModel.buildEditTemplate(event, verbatim, StatusTag.STATUS.CLOSED)
accountViewModel.account.signAndComputeBroadcast(template)
true
} catch (_: Throwable) {
false
}
}
/** /**
* Build an `nostr:naddr1...` URI for the room and hand it to the * Build an `nostr:naddr1...` URI for the room and hand it to the
* system share sheet. Includes the room title in the share text * system share sheet. Includes the room title in the share text
+5
View File
@@ -529,6 +529,11 @@
<string name="audio_room_notification_stop">Stop</string> <string name="audio_room_notification_stop">Stop</string>
<string name="audio_room_join">Join audio room</string> <string name="audio_room_join">Join audio room</string>
<string name="audio_room_leave">Leave</string> <string name="audio_room_leave">Leave</string>
<string name="audio_room_leave_host_title">End this audio room?</string>
<string name="audio_room_leave_host_body">You\'re the host. Closing the room will disconnect everyone. Choose \"Just leave\" if you want to come back later — the room will auto-close after 8 hours of inactivity.</string>
<string name="audio_room_leave_host_close">Close room</string>
<string name="audio_room_leave_host_just_leave">Just leave</string>
<string name="audio_room_leave_host_close_failed">Couldn\'t mark room as closed. Leaving anyway — room will auto-close.</string>
<plurals name="audio_room_listener_count"> <plurals name="audio_room_listener_count">
<item quantity="one">%1$d listener</item> <item quantity="one">%1$d listener</item>
<item quantity="other">%1$d listeners</item> <item quantity="other">%1$d listeners</item>