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:
Claude
2026-04-26 22:26:23 +00:00
parent 3e3ce4ea3c
commit e9aefed496
5 changed files with 571 additions and 2 deletions
@@ -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(
@@ -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))
}
}
}
}
}
}
}
@@ -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,
)
}
}
}
+4
View File
@@ -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>
@@ -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" })
}
}