Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList.

This commit is contained in:
KotlinGeekDev
2025-11-27 07:08:08 +01:00
parent 576d20723d
commit 0f21c72936
7 changed files with 482 additions and 3 deletions
@@ -30,11 +30,10 @@ data class LabeledBookmarkList(
val identifier: String,
val title: String,
val description: String?,
val image: String?,
val privateBookmarks: Set<BookmarkIdTag> = emptySet(),
val publicBookmarks: Set<BookmarkIdTag> = emptySet(),
) {
// TODO: Add methods for LInk and Hashtag, after their implementation.
val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
@@ -30,9 +30,13 @@ import com.vitorpamplona.amethyst.model.events
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -85,6 +89,7 @@ class LabeledBookmarkListsState(
identifier = dTag(),
title = nameOrTitle() ?: dTag(),
description = description(),
image = image(),
privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(),
publicBookmarks = publicBookmarks().toSet(),
)
@@ -103,6 +108,8 @@ class LabeledBookmarkListsState(
it.identifier == bookmarkListId
}
fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag)
fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value)
fun hasItemInNoteList(notes: Set<Note>): Boolean =
@@ -146,6 +153,7 @@ class LabeledBookmarkListsState(
suspend fun addLabeledBookmarkList(
listName: String,
listDescription: String? = null,
listImage: String? = null,
firstBookmark: BookmarkIdTag? = null,
isBookmarkPrivate: Boolean = false,
account: Account,
@@ -154,6 +162,7 @@ class LabeledBookmarkListsState(
LabeledBookmarkListEvent.create(
name = listName,
description = listDescription,
image = listImage,
publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
privateBookmarks = if (isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
signer = account.signer,
@@ -161,6 +170,27 @@ class LabeledBookmarkListsState(
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun updateMetadata(
listName: String?,
listDescription: String?,
listImage: String?,
bookmarkList: LabeledBookmarkList,
account: Account,
) {
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
val template =
listEvent.update {
if (listName != null) name(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun renameBookmarkList(
newName: String,
bookmarkList: LabeledBookmarkList,
@@ -0,0 +1,247 @@
/**
* 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.bookmarkgroups.list.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun BookmarkGroupMetadataScreen(
bookmarkGroupIdentifier: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel = viewModel()
bookmarkGroupInfoViewModel.init(accountViewModel)
if (bookmarkGroupIdentifier != null) {
LaunchedEffect(bookmarkGroupInfoViewModel) {
bookmarkGroupInfoViewModel.load(bookmarkGroupIdentifier)
}
} else {
LaunchedEffect(bookmarkGroupInfoViewModel) {
bookmarkGroupInfoViewModel.new()
}
}
BookmarkGroupMetadataScaffold(bookmarkGroupInfoViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BookmarkGroupMetadataScaffold(
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
BookmarkGroupMetadataTopBar(
bookmarkGroupInfoViewModel = bookmarkGroupInfoViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
R.string.bookmark_list_edit_sub_title,
R.string.bookmark_list_explainer,
SettingsCategoryFirstModifier,
)
ListName(bookmarkGroupInfoViewModel)
Spacer(modifier = DoubleVertSpacer)
Picture(bookmarkGroupInfoViewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer)
Description(bookmarkGroupInfoViewModel)
}
}
}
}
@Composable
fun BookmarkGroupMetadataTopBar(
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (bookmarkGroupInfoViewModel.isNewList) {
CreatingTopBar(
titleRes = R.string.bookmark_list_creation_screen_title,
isActive = bookmarkGroupInfoViewModel::canPost,
onCancel = {
bookmarkGroupInfoViewModel.clear()
nav.popBack()
},
onPost = {
try {
bookmarkGroupInfoViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.follow_set_edit_list_metadata,
isActive = bookmarkGroupInfoViewModel::canPost,
onCancel = {
bookmarkGroupInfoViewModel.clear()
nav.popBack()
},
onPost = {
try {
bookmarkGroupInfoViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun Description(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) },
modifier = Modifier.fillMaxWidth(),
value = bookmarkGroupInfoViewModel.description.value,
onValueChange = { bookmarkGroupInfoViewModel.description.value = it },
placeholder = {
Text(
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@Composable
private fun Picture(
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.optional_picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = bookmarkGroupInfoViewModel.picture.value,
onValueChange = { bookmarkGroupInfoViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = bookmarkGroupInfoViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp),
) {
bookmarkGroupInfoViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
)
}
@Composable
private fun ListName(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = bookmarkGroupInfoViewModel.name.value,
onValueChange = { bookmarkGroupInfoViewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.follow_set_copy_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,188 @@
/**
* 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.bookmarkgroups.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class BookmarkGroupMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var bookmarkGroup by mutableStateOf<LabeledBookmarkList?>(null)
val isNewList by derivedStateOf { bookmarkGroup == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
bookmarkGroup = null
clear()
}
fun load(dTag: String) {
bookmarkGroup = account.labeledBookmarkLists.getBookmarkList(dTag)
name.value = TextFieldValue(bookmarkGroup?.title ?: "")
picture.value = TextFieldValue(bookmarkGroup?.image ?: "")
description.value = TextFieldValue(bookmarkGroup?.description ?: "")
}
fun isNewChannel() = bookmarkGroup == null
fun createOrUpdate() {
accountViewModel.launchSigner {
val bookmarkGroup = bookmarkGroup
if (bookmarkGroup == null) {
accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.labeledBookmarkLists.updateMetadata(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
bookmarkList = bookmarkGroup,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
+4
View File
@@ -117,6 +117,7 @@
<string name="channel_name">Channel Name</string>
<string name="my_awesome_group">My Awesome Group</string>
<string name="picture_url">Picture Url</string>
<string name="optional_picture_url">Picture Url(Optional)</string>
<string name="description">Description</string>
<string name="no_description">Description not found</string>
<string name="about_us">"About us.. "</string>
@@ -391,6 +392,8 @@
<string name="bookmark_lists">Bookmark Lists</string>
<string name="bookmark_list_icon_label">Icon for bookmark list</string>
<string name="bookmark_list_creation_screen_title">New Bookmark List</string>
<string name="bookmark_list_edit_sub_title">Bookmark List Metadata</string>
<string name="bookmark_list_clone_btn_label">Clone Bookmark List</string>
<string name="bookmark_list_broadcast_btn_label">Broadcast Bookmark List</string>
<string name="bookmark_list_delete_btn_label">Delete Bookmark List</string>
@@ -418,6 +421,7 @@
<string name="public_bookmark_add_action_label">Add as public bookmark</string>
<string name="private_bookmark_add_action_label">Add as private bookmark</string>
<string name="bookmark_remove_action_label">Remove from Bookmark List</string>
<string name="bookmark_list_explainer">Bookmark lists metadata can be seen by anyone on Nostr. Only your private members are encrypted.</string>
<string name="wallet_connect_service">Wallet Connect Service</string>
<string name="wallet_connect_service_explainer">Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible</string>
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.replaceAll
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -75,6 +76,8 @@ class LabeledBookmarkListEvent(
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun countBookmarks() = tags.count(BookmarkIdTag::isTagged)
fun publicBookmarks(): List<BookmarkIdTag> = tags.mapNotNull(BookmarkIdTag::parse)
@@ -261,6 +264,7 @@ class LabeledBookmarkListEvent(
suspend fun create(
name: String = "",
description: String? = null,
image: String? = null,
publicBookmarks: List<BookmarkIdTag> = emptyList(),
privateBookmarks: List<BookmarkIdTag> = emptyList(),
dTag: String = Uuid.random().toString(),
@@ -269,7 +273,8 @@ class LabeledBookmarkListEvent(
): LabeledBookmarkListEvent {
val template =
build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) {
if (description != null) addUnique(DescriptionTag.assemble(description))
if (description != null) description(description)
if (image != null) image(image)
}
return signer.sign(template)
}
@@ -22,8 +22,14 @@ package com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
fun TagArrayBuilder<LabeledBookmarkListEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<LabeledBookmarkListEvent>.bookmarks(bookmarks: List<BookmarkIdTag>) = addAll(bookmarks.map { it.toTagArray() })
fun TagArrayBuilder<LabeledBookmarkListEvent>.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription))
fun TagArrayBuilder<LabeledBookmarkListEvent>.image(url: String) = addUnique(ImageTag.assemble(url))