feat(audio-rooms): host edit + close room (T1 #6)
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.
This commit is contained in:
+46
-2
@@ -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(
|
||||
|
||||
+173
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -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<FormState> = _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<MeetingSpaceEvent> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,6 +530,10 @@
|
||||
<string name="audio_room_chat_empty">No messages yet. Be the first to chat.</string>
|
||||
<string name="audio_room_reactions_title">React</string>
|
||||
<string name="audio_room_reactions_button">React</string>
|
||||
<string name="audio_room_edit_title">Edit room</string>
|
||||
<string name="audio_room_edit_save">Save</string>
|
||||
<string name="audio_room_close_action">Close room</string>
|
||||
<string name="audio_room_overflow_menu">Room actions</string>
|
||||
<string name="audio_room_create_fab">Start space</string>
|
||||
<string name="audio_room_create_title">Start a new audio room</string>
|
||||
<string name="audio_room_create_field_room">Room name</string>
|
||||
|
||||
+153
@@ -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<Array<String>> = emptyList(),
|
||||
): MeetingSpaceEvent {
|
||||
val tags =
|
||||
buildList<Array<String>> {
|
||||
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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user