diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b5f477560a..d9247f6755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, ): 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f93988af4..8026e282b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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(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(null) } + var channelToDelete by remember { mutableStateOf(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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 285954831a..68c03da388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -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(null) } + val banner = remember { mutableStateOf(null) } + val relays = remember { mutableStateListOf() } 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index ec6254a156..0395f31258 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -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? = 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, + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f7bbe180e6..f80f1a97bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -314,6 +314,16 @@ Show all channels Send image Open channel + Add a banner + New channel + Rename channel + Rename + Channel name + Delete channel + Delete channel? + Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id. + Delete + Where this community\'s encrypted planes are published and read. %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index c38f3858a4..31c25887cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -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, + 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