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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.nip19Bech32.toNAddr
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlinx.coroutines.launch
/**
* Full-screen layout for [AudioRoomActivity]. Renders title + summary,
@@ -98,7 +104,9 @@ internal fun AudioRoomFullScreen(
) {
var showEditSheet 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 leaveScope = rememberCoroutineScope()
Row(
modifier = Modifier.fillMaxWidth(),
@@ -256,7 +264,15 @@ internal fun AudioRoomFullScreen(
OutlinedButton(onClick = { showReactionPicker = true }) {
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))
}
}
@@ -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(
roomATag =
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
* 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_join">Join audio room</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">
<item quantity="one">%1$d listener</item>
<item quantity="other">%1$d listeners</item>