feat(concord): channel management + community banner & relay editing

Two ways to update a Concord community/its channels that were missing:

Channels (net-new): ConcordModeration.defineChannel writes a ChannelEntity
control edition (create/rename/delete via version chaining); Account gains
createConcordChannel/renameConcordChannel/deleteConcordChannel. The channel-list
screen gets a create FAB and a per-row rename/delete menu, all gated on
MANAGE_CHANNELS (the same predicate the fold enforces).

Community metadata: the edit screen now edits the banner (encrypted ImagePointer
upload via the shared banner hero, reusing ConcordImageUploader) and the relay
set (add/remove chips + RelayUrlEditField). Also fixes editConcordMetadata
silently dropping the banner on every save (it now round-trips it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-14 23:36:50 +00:00
parent d84555a27b
commit 7cd2be1c74
6 changed files with 384 additions and 5 deletions
@@ -151,6 +151,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
@@ -2512,16 +2513,63 @@ class Account(
name: String,
description: String?,
icon: ImagePointer?,
banner: ImagePointer?,
relays: List<String>,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val metadata = MetadataEntity(name = name, icon = icon, description = description, relays = relays)
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/**
* Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold
* only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on
* the same predicate. The channel id is a fresh random 32-byte entity id.
*/
suspend fun createConcordChannel(
communityId: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */
suspend fun renameConcordChannel(
communityId: String,
channelIdHex: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */
suspend fun deleteConcordChannel(
communityId: String,
channelIdHex: String,
name: String,
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim(), deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
publishConcordWrap(session.entry, wrap)
return true
}
/**
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
@@ -33,10 +33,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -92,10 +96,62 @@ fun ConcordChannelListScreen(
var inviteLink by remember { mutableStateOf<String?>(null) }
var minting by remember { mutableStateOf(false) }
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
val canManageChannels =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
} == true
// channelIdHex == null → create; else → rename that channel.
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
var channelToDelete by remember { mutableStateOf<ConcordChannelEditor?>(null) }
inviteLink?.let { link ->
InviteLinkDialog(link = link, onDismiss = { inviteLink = null })
}
channelEditor?.let { editor ->
ConcordChannelEditDialog(
initialName = editor.initialName,
isCreate = editor.channelIdHex == null,
onDismiss = { channelEditor = null },
onConfirm = { newName ->
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.createConcordChannel(communityId, newName)
} else {
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
}
},
)
}
channelToDelete?.let { target ->
val id = target.channelIdHex ?: return@let
AlertDialog(
onDismissRequest = { channelToDelete = null },
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_title)) },
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_message, target.initialName)) },
confirmButton = {
TextButton(onClick = {
channelToDelete = null
scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) }
}) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm))
}
},
dismissButton = {
TextButton(onClick = { channelToDelete = null }) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -135,6 +191,13 @@ fun ConcordChannelListScreen(
},
)
},
floatingActionButton = {
if (canManageChannels) {
FloatingActionButton(onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") }) {
SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_create))
}
}
},
) { padding ->
val channels =
state
@@ -165,7 +228,7 @@ fun ConcordChannelListScreen(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -175,7 +238,13 @@ fun ConcordChannelListScreen(
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
if (canManageChannels) {
ConcordChannelRowMenu(
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
)
}
}
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
@@ -184,6 +253,106 @@ fun ConcordChannelListScreen(
}
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,
val initialName: String,
)
/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */
@Composable
private fun ConcordChannelRowMenu(
onRename: () -> Unit,
onDelete: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
SymbolIcon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_rename)) },
onClick = {
expanded = false
onRename()
},
)
DropdownMenuItem(
text = {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
expanded = false
onDelete()
},
)
}
}
}
/** Name-entry dialog for creating a new channel or renaming an existing one. */
@Composable
private fun ConcordChannelEditDialog(
initialName: String,
isCreate: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringRes(
if (isCreate) {
com.vitorpamplona.amethyst.R.string.concord_channel_create
} else {
com.vitorpamplona.amethyst.R.string.concord_channel_rename
},
),
)
},
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_name_label)) },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
) {
Text(
stringRes(
if (isCreate) {
com.vitorpamplona.amethyst.R.string.concord_channel_create
} else {
com.vitorpamplona.amethyst.R.string.concord_channel_rename_save
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
/** Shows a freshly minted invite link as a QR code with copy + share actions. */
@Composable
private fun InviteLinkDialog(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -32,12 +33,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -52,8 +55,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -84,17 +91,24 @@ fun ConcordEditScreen(
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val icon = remember { mutableStateOf<ImagePointer?>(null) }
val banner = remember { mutableStateOf<ImagePointer?>(null) }
val relays = remember { mutableStateListOf<NormalizedRelayUrl>() }
var prefilled by remember { mutableStateOf(false) }
var working by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Seed the fields once, the first time the folded metadata is available.
// Seed the fields once, the first time the folded metadata is available. Relays come from the
// folded metadata when present, else from this account's list entry (the bootstrap set).
LaunchedEffect(state?.metadata) {
val md = state?.metadata
if (!prefilled && md != null) {
name.value = md.name
about.value = md.description.orEmpty()
icon.value = md.icon
banner.value = md.banner
val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty())
relays.clear()
relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) })
prefilled = true
}
}
@@ -132,6 +146,26 @@ fun ConcordEditScreen(
icon = icon,
robotSeed = communityId,
accountViewModel = accountViewModel,
banner = banner,
)
ConcordSectionHeader(
title = stringRes(R.string.concord_create_relays),
description = stringRes(R.string.concord_edit_relays_desc),
)
relays.forEach { relay ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = { relays.remove(relay) }) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}
RelayUrlEditField(
onNewRelay = { if (it !in relays) relays.add(it) },
modifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
nav = nav,
)
Button(
@@ -145,7 +179,8 @@ fun ConcordEditScreen(
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
icon = icon.value,
relays = state?.metadata?.relays ?: session.entry.relays,
banner = banner.value,
relays = relays.map { it.url },
)
working = false
if (ok) nav.popBack()
@@ -24,15 +24,20 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -46,17 +51,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The shared metadata form for creating and editing a Concord community a large circular icon
@@ -74,12 +83,15 @@ fun ConcordMetadataFields(
robotSeed: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
banner: MutableState<ImagePointer?>? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
banner?.let { ConcordBannerHero(banner = it, accountViewModel = accountViewModel) }
ConcordIconHero(
robotSeed = robotSeed,
icon = icon,
@@ -173,3 +185,86 @@ private fun ConcordIconHero(
)
}
}
/**
* A wide community-banner hero (a 3:1 header image): shows the current decrypted banner, and on tap
* opens the photo picker AES-256-GCM-encrypts + uploads the image and updates [banner] to the
* resulting CORD-02 §6 encrypted pointer. Tapping when a banner is set replaces it; a small remove
* button clears it. A spinner covers the hero while the upload is in flight.
*/
@Composable
private fun ConcordBannerHero(
banner: MutableState<ImagePointer?>,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var uploading by remember { mutableStateOf(false) }
val bannerModel = rememberConcordImageModel(banner.value, accountViewModel)
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
uploading = true
scope.launch {
try {
banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
} catch (e: Exception) {
Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show()
} finally {
uploading = false
}
}
}
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(3f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
contentAlignment = Alignment.Center,
) {
if (bannerModel != null) {
AsyncImage(
model = bannerModel,
contentDescription = stringRes(R.string.concord_edit_banner_hint),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(3f),
)
}
if (uploading) {
CircularProgressIndicator(modifier = Modifier.size(36.dp))
} else if (bannerModel == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
SymbolIcon(
symbol = MaterialSymbols.AddPhotoAlternate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(R.string.concord_edit_banner_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 6.dp),
)
}
}
if (bannerModel != null && !uploading) {
IconButton(
onClick = { banner.value = null },
modifier = Modifier.align(Alignment.TopEnd),
) {
SymbolIcon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.remove),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
+10
View File
@@ -314,6 +314,16 @@
<string name="concord_show_all_channels">Show all channels</string>
<string name="concord_send_image_title">Send image</string>
<string name="concord_open_channel">Open channel</string>
<string name="concord_edit_banner_hint">Add a banner</string>
<string name="concord_channel_create">New channel</string>
<string name="concord_channel_rename">Rename channel</string>
<string name="concord_channel_rename_save">Rename</string>
<string name="concord_channel_name_label">Channel name</string>
<string name="concord_channel_delete">Delete channel</string>
<string name="concord_channel_delete_title">Delete channel?</string>
<string name="concord_channel_delete_message">Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id.</string>
<string name="concord_channel_delete_confirm">Delete</string>
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
<string name="concord_typing_one">%1$s is typing…</string>
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
<string name="concord_typing_many">Several people are typing…</string>
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
@@ -97,6 +98,27 @@ object ConcordModeration {
return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation)
}
/**
* Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable
* 32-byte entity id generate one for a new channel and reuse it to rename, flip its
* private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused).
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner
* via [citation].
*/
suspend fun defineChannel(
actor: NostrSigner,
controlPlane: GroupKey,
channelId: ByteArray,
channel: ChannelEntity,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val (version, prev) = versioning(current, ControlEntityKind.CHANNEL, channelId)
val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel)
return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation)
}
/**
* Replaces the community metadata (name / icon / description / relays). The
* metadata entity id is the community id itself (as in genesis), so this chains