mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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
|
||||
|
||||
+171
-2
@@ -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(
|
||||
|
||||
+37
-2
@@ -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()
|
||||
|
||||
+95
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user