From e9aefed496b9edeb7810209f61ffd52f2af02c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:26:23 +0000 Subject: [PATCH] feat(audio-rooms): host edit + close room (T1 #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the host-only "edit / close room" flow: EditAudioRoomViewModel — pre-fills from the existing MeetingSpaceEvent on bind. save() and closeRoom() both republish kind-30312 with the SAME `d`-tag (relay treats it as a replacement). Closing flips status=CLOSED; everything else just edits the form fields. Participants are preserved verbatim — promoting a speaker is a separate action (Tier 1 #5), so edit must NEVER silently demote anyone. buildEditTemplate — pure template builder extracted out so it's unit-testable without an AccountViewModel / signer (the sign+broadcast path is the only side effect). EditAudioRoomSheet — bottom-sheet copy of CreateAudioRoomSheet with a destructive "Close room" button on the left and "Save" on the right. Title, error and progress states wired to the same VM. AudioRoomFullScreen — host-only overflow menu (kebab) next to the room title; "Edit room" opens the sheet. Hidden when the local pubkey isn't the room's pubkey. EditAudioRoomViewModelTest — 4 unit tests covering: * republish keeps the same d-tag and updates room/summary * republish preserves all promoted participants (host + 2 speakers) — none lost on rename * closeRoom flips status to CLOSED with the same d-tag * blank summary / image OMIT the corresponding tags so a "delete the summary" edit actually deletes it Strings — `audio_room_edit_title`, `audio_room_edit_save`, `audio_room_close_action`, `audio_room_overflow_menu`. Scheduled rooms (#7) deliberately deferred to a follow-up — the StatusTag enum currently has only OPEN/PRIVATE/CLOSED on MeetingSpaceEvent, no PLANNED. That's an additive Quartz change plus a date-time picker on the create flow; better to land in its own commit. --- .../audiorooms/room/AudioRoomFullScreen.kt | 48 ++++- .../audiorooms/room/EditAudioRoomSheet.kt | 173 ++++++++++++++++ .../audiorooms/room/EditAudioRoomViewModel.kt | 195 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../room/EditAudioRoomViewModelTest.kt | 153 ++++++++++++++ 5 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModelTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 71ccce295..bce5cf792 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -25,6 +25,7 @@ import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -94,8 +95,51 @@ internal fun AudioRoomFullScreen( .padding(16.dp) .verticalScroll(rememberScrollState()), ) { - event.room()?.let { - Text(text = it, style = MaterialTheme.typography.headlineSmall) + var showEditSheet by rememberSaveable { mutableStateOf(false) } + var showHostMenu by rememberSaveable { mutableStateOf(false) } + val isHost = accountViewModel.account.signer.pubKey == event.pubKey + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + event.room()?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + } + if (isHost) { + Box { + androidx.compose.material3.IconButton(onClick = { showHostMenu = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.audio_room_overflow_menu), + ) + } + androidx.compose.material3.DropdownMenu( + expanded = showHostMenu, + onDismissRequest = { showHostMenu = false }, + ) { + androidx.compose.material3.DropdownMenuItem( + text = { Text(stringRes(R.string.audio_room_edit_title)) }, + onClick = { + showHostMenu = false + showEditSheet = true + }, + ) + } + } + } + } + if (showEditSheet) { + EditAudioRoomSheet( + accountViewModel = accountViewModel, + event = event, + onDismiss = { showEditSheet = false }, + ) } event.summary()?.let { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt new file mode 100644 index 000000000..8f500352a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomSheet.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import kotlinx.coroutines.launch + +/** + * Bottom sheet that lets the host edit the current room — pre-fills + * from the existing kind-30312 event, republishes with the same + * `d`-tag on save (so the relay treats it as a replacement of the + * original), or closes the room with a destructive button. + * + * Visibility gating (host-only) is the caller's job; + * [EditAudioRoomViewModel] preserves every promoted participant + * verbatim, so a save MUST NOT silently demote anyone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAudioRoomSheet( + accountViewModel: AccountViewModel, + event: MeetingSpaceEvent, + onDismiss: () -> Unit, +) { + val key = remember(event) { "EditAudioRoom-${event.dTag()}" } + val viewModel: EditAudioRoomViewModel = viewModel(key = key) + LaunchedEffect(viewModel, event) { viewModel.bind(accountViewModel, event) } + + val state by viewModel.state.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.audio_room_edit_title), + style = MaterialTheme.typography.titleLarge, + ) + + OutlinedTextField( + value = state.roomName, + onValueChange = viewModel::setRoomName, + label = { Text(stringRes(R.string.audio_room_create_field_room)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.summary, + onValueChange = viewModel::setSummary, + label = { Text(stringRes(R.string.audio_room_create_field_summary)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.serviceUrl, + onValueChange = viewModel::setServiceUrl, + label = { Text(stringRes(R.string.audio_room_create_field_service)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.endpointUrl, + onValueChange = viewModel::setEndpointUrl, + label = { Text(stringRes(R.string.audio_room_create_field_endpoint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.imageUrl, + onValueChange = viewModel::setImageUrl, + label = { Text(stringRes(R.string.audio_room_create_field_image)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + state.error?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(4.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + // Destructive — flips status to CLOSED with the same d-tag + // so subscribers see the room go dark. + TextButton( + enabled = !state.isPublishing, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + onClick = { + scope.launch { + if (viewModel.closeRoom()) onDismiss() + } + }, + ) { + Text(stringRes(R.string.audio_room_close_action)) + } + Row { + TextButton(onClick = onDismiss, enabled = !state.isPublishing) { + Text(stringRes(R.string.audio_room_create_cancel)) + } + Spacer(Modifier.width(8.dp)) + Button( + enabled = state.canSubmit, + onClick = { + scope.launch { + if (viewModel.save()) onDismiss() + } + }, + ) { + if (state.isPublishing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(stringRes(R.string.audio_room_edit_save)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModel.kt new file mode 100644 index 000000000..e2487025e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModel.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.endpoint +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.image +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.participants +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Backing ViewModel for [EditAudioRoomSheet]. Loads the existing + * [MeetingSpaceEvent], lets the host edit room name / summary / + * image / endpoint, then republishes kind-30312 with the SAME `d` + * tag so the relay treats it as a replacement of the original. + * + * Closing the room ([closeRoom]) is the same path with + * [StatusTag.STATUS.CLOSED] forced regardless of the form fields, + * so a host can close without editing anything else. + * + * Participants are preserved verbatim — re-publishing must NOT lose + * speakers or admins who were promoted in a previous version of + * the event (audit risk note in the Tier-1 plan). + */ +class EditAudioRoomViewModel : ViewModel() { + @Volatile private var account: AccountViewModel? = null + + @Volatile private var original: MeetingSpaceEvent? = null + + private val _state = MutableStateFlow(FormState.empty()) + val state: StateFlow = _state.asStateFlow() + + fun bind( + accountViewModel: AccountViewModel, + existing: MeetingSpaceEvent, + ) { + account = accountViewModel + original = existing + // Only seed the form once per (sheet open, event) pair so a + // recomposition of the sheet doesn't blow away the user's + // unsaved edits. + if (_state.value.dTag.isEmpty()) { + _state.value = + FormState( + dTag = existing.dTag(), + roomName = existing.room().orEmpty(), + summary = existing.summary().orEmpty(), + imageUrl = existing.image().orEmpty(), + endpointUrl = existing.endpoint().orEmpty(), + serviceUrl = existing.service().orEmpty(), + isPublishing = false, + error = null, + ) + } + } + + fun setRoomName(value: String) = _state.update { it.copy(roomName = value) } + + fun setSummary(value: String) = _state.update { it.copy(summary = value) } + + fun setImageUrl(value: String) = _state.update { it.copy(imageUrl = value) } + + fun setEndpointUrl(value: String) = _state.update { it.copy(endpointUrl = value) } + + fun setServiceUrl(value: String) = _state.update { it.copy(serviceUrl = value) } + + /** Publish the edited room. Returns `true` on success. */ + suspend fun save(): Boolean = republish(StatusTag.STATUS.OPEN) + + /** Close the room (host only). Republishes with status=CLOSED. */ + suspend fun closeRoom(): Boolean = republish(StatusTag.STATUS.CLOSED) + + private suspend fun republish(targetStatus: StatusTag.STATUS): Boolean { + val avm = account ?: return false + val originalEvent = original ?: return false + val current = _state.value + + if (current.roomName.isBlank() || current.serviceUrl.isBlank() || current.endpointUrl.isBlank()) { + _state.update { it.copy(error = "Room name, service URL and endpoint are required.") } + return false + } + + _state.update { it.copy(isPublishing = true, error = null) } + + val template = buildEditTemplate(originalEvent, current, targetStatus) + + return try { + avm.account.signAndComputeBroadcast(template) + _state.update { it.copy(isPublishing = false) } + true + } catch (t: Throwable) { + _state.update { + it.copy( + isPublishing = false, + error = "Failed to publish: ${t.message ?: t::class.simpleName}", + ) + } + false + } + } + + companion object { + /** + * Pure template builder — extracted so it can be unit-tested + * without an AccountViewModel / signer. The republished + * template MUST reuse the original `dTag` and preserve every + * participant tag verbatim, including the host. + */ + internal fun buildEditTemplate( + original: MeetingSpaceEvent, + form: FormState, + status: StatusTag.STATUS, + ): com.vitorpamplona.quartz.nip01Core.signers.EventTemplate { + val allParticipants = original.participants() + val host = + allParticipants.firstOrNull { it.role.equals(ROLE.HOST.code, true) } + ?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null) + val others = allParticipants.filterNot { it.pubKey == host.pubKey } + + return MeetingSpaceEvent.build( + room = form.roomName.trim(), + status = status, + service = form.serviceUrl.trim(), + host = host, + dTag = form.dTag, + ) { + endpoint(form.endpointUrl.trim()) + form.summary + .trim() + .takeIf { it.isNotBlank() } + ?.let { summary(it) } + form.imageUrl + .trim() + .takeIf { it.isNotBlank() } + ?.let { image(it) } + if (others.isNotEmpty()) participants(others) + } + } + } + + data class FormState( + val dTag: String, + val roomName: String, + val summary: String, + val imageUrl: String, + val endpointUrl: String, + val serviceUrl: String, + val isPublishing: Boolean, + val error: String?, + ) { + val canSubmit: Boolean + get() = roomName.isNotBlank() && serviceUrl.isNotBlank() && endpointUrl.isNotBlank() && !isPublishing + + companion object { + fun empty() = + FormState( + dTag = "", + roomName = "", + summary = "", + imageUrl = "", + endpointUrl = "", + serviceUrl = "", + isPublishing = false, + error = null, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cdbf74f1b..79dd5cf1a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -530,6 +530,10 @@ No messages yet. Be the first to chat. React React + Edit room + Save + Close room + Room actions Start space Start a new audio room Room name diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModelTest.kt new file mode 100644 index 000000000..cf2469d72 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/EditAudioRoomViewModelTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditAudioRoomViewModelTest { + private val host = "a".repeat(64) + private val speakerA = "b".repeat(64) + private val speakerB = "c".repeat(64) + + private fun original( + dTag: String = "rt-1", + roomName: String = "Old Name", + summary: String = "Old summary", + endpoint: String = "https://moq.example.com", + service: String = "https://auth.example.com", + extraParticipants: List> = emptyList(), + ): MeetingSpaceEvent { + val tags = + buildList> { + add(arrayOf("d", dTag)) + add(arrayOf("room", roomName)) + add(arrayOf("status", StatusTag.STATUS.OPEN.code)) + add(arrayOf("service", service)) + add(arrayOf("endpoint", endpoint)) + add(arrayOf("summary", summary)) + add(arrayOf("p", host, "", "host")) + addAll(extraParticipants) + }.toTypedArray() + return MeetingSpaceEvent( + id = "0".repeat(64), + pubKey = host, + createdAt = 100L, + tags = tags, + content = "", + sig = "0".repeat(128), + ) + } + + private fun form( + dTag: String, + roomName: String, + summary: String = "", + imageUrl: String = "", + endpointUrl: String = "https://moq.example.com", + serviceUrl: String = "https://auth.example.com", + ) = EditAudioRoomViewModel.FormState( + dTag = dTag, + roomName = roomName, + summary = summary, + imageUrl = imageUrl, + endpointUrl = endpointUrl, + serviceUrl = serviceUrl, + isPublishing = false, + error = null, + ) + + @Test + fun republishKeepsSameDTagAndUpdatesFields() { + val src = original(dTag = "rt-42", roomName = "Old", summary = "Old summary") + val newForm = form(dTag = "rt-42", roomName = "New name", summary = "New summary") + + val template = + EditAudioRoomViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN) + + // Same d-tag → same address → relay treats as replacement. + val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) + assertEquals("rt-42", dTag) + + val roomTag = template.tags.firstOrNull { it.firstOrNull() == "room" }?.getOrNull(1) + assertEquals("New name", roomTag) + + val summaryTag = template.tags.firstOrNull { it.firstOrNull() == "summary" }?.getOrNull(1) + assertEquals("New summary", summaryTag) + } + + @Test + fun republishPreservesAllPromotedParticipants() { + // Two extra speakers were promoted in the original event. + val src = + original( + dTag = "rt-1", + extraParticipants = + listOf( + arrayOf("p", speakerA, "", "speaker"), + arrayOf("p", speakerB, "", "speaker"), + ), + ) + val newForm = form(dTag = "rt-1", roomName = "Renamed") + + val template = + EditAudioRoomViewModel.buildEditTemplate(src, newForm, StatusTag.STATUS.OPEN) + + val pTagPubkeys = template.tags.filter { it.firstOrNull() == "p" }.map { it[1] } + // Host plus two speakers — none can be lost on a rename. + assertTrue("host preserved", host in pTagPubkeys) + assertTrue("speakerA preserved", speakerA in pTagPubkeys) + assertTrue("speakerB preserved", speakerB in pTagPubkeys) + assertEquals("no duplicate p-tags", 3, pTagPubkeys.size) + } + + @Test + fun closeRoomFlipsStatusAndKeepsDTag() { + val src = original(dTag = "rt-99") + val sameForm = form(dTag = "rt-99", roomName = src.room().orEmpty()) + + val template = + EditAudioRoomViewModel.buildEditTemplate(src, sameForm, StatusTag.STATUS.CLOSED) + + val statusTag = template.tags.firstOrNull { it.firstOrNull() == "status" }?.getOrNull(1) + assertEquals(StatusTag.STATUS.CLOSED.code, statusTag) + val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) + assertEquals("rt-99", dTag) + } + + @Test + fun emptySummaryAndImageOmitTags() { + val src = original(dTag = "rt-1", roomName = "X", summary = "anything") + val emptied = form(dTag = "rt-1", roomName = "X", summary = " ", imageUrl = "") + + val template = + EditAudioRoomViewModel.buildEditTemplate(src, emptied, StatusTag.STATUS.OPEN) + + // Blank inputs MUST NOT carry over from the original; otherwise + // a "delete the summary" edit would silently fail. + assertNull(template.tags.firstOrNull { it.firstOrNull() == "summary" }) + assertNull(template.tags.firstOrNull { it.firstOrNull() == "image" }) + } +}