feat(concord): members roster + edit-metadata screens, redesigned create form

Adds the two management screens the NIP-29 relay groups have but Concord lacked,
plus a visual overhaul of the create screen:

- Members roster (ConcordMembersScreen): owner + role-holders + banned, each with
  avatar/name and owner/admin/banned badge; overflow menu promotes/demotes (owner)
  and bans/unbans, gated on the viewer's authority. New AuthorityResolver.roleHolders()
  and bannedMembers() accessors expose the privileged roster (membership is otherwise
  key possession, so there is no silent-member list).
- Edit metadata (ConcordEditScreen): new write path — ConcordModeration.editMetadata
  (METADATA edition, entityId = communityId) + Account.editConcordMetadata; prefilled
  from the folded state, honored on fold only for MANAGE_METADATA holders / owner.
- Create screen redesign: shared ConcordMetadataFields with a circular icon hero
  (live preview from the URL) + section headers, mirroring the NIP-29 GroupImagePicker
  layout, replacing the bare stack of text fields.
- Routes ConcordMembers/ConcordEdit + nav registration + top-bar entry points on the
  channel-list screen (Members always, Edit for those who can manage metadata).
- Account.peekConcordInvite for the upcoming invite card.

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-11 01:50:49 +00:00
parent 0fae8b4966
commit 73c6bd6180
12 changed files with 730 additions and 37 deletions
@@ -132,7 +132,9 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -1826,6 +1828,42 @@ class Account(
return true
}
/**
* Replace the community metadata (name / icon / description / relays) with a new
* Control-Plane edition. Honored on fold only when this account holds
* MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition.
*/
suspend fun editConcordMetadata(
communityId: String,
name: String,
description: String?,
icon: String?,
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 wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, 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
* joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a
* card can show what the link opens, or null if the link is invalid/unreadable.
*/
suspend fun peekConcordInvite(url: String): CommunityInvite? {
val parsed = ConcordActions.parseInviteLink(url) ?: return null
val relays =
(parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet()
if (relays.isEmpty()) return null
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
}
// ── NIP-29 relay-group actions ───────────────────────────────────────────
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
@@ -103,8 +103,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGro
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
@@ -608,6 +610,22 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.ConcordMembers> {
ConcordMembersScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordInvite> {
ConcordInviteScreen(
link = it.link,
@@ -690,6 +690,14 @@ sealed class Route {
val communityId: String,
) : Route()
@Serializable data class ConcordMembers(
val communityId: String,
) : Route()
@Serializable data class ConcordEdit(
val communityId: String,
) : Route()
@Serializable object ConcordCreate : Route()
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
@@ -584,6 +584,24 @@ class AccountViewModel(
}
}
/** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */
fun setConcordAdmin(
communityId: String,
member: HexKey,
makeAdmin: Boolean,
) = launchSigner {
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
}
/** Ban/unban [member] from [communityId] (from the Members roster). */
fun setConcordBan(
communityId: String,
member: HexKey,
ban: Boolean,
) = launchSigner {
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,
@@ -63,6 +63,7 @@ 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.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -103,6 +104,20 @@ fun ConcordChannelListScreen(
}
},
actions = {
val canEdit =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
} == true
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
}
if (canEdit) {
IconButton(onClick = { nav.nav(Route.ConcordEdit(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
}
}
IconButton(
enabled = !minting,
onClick = {
@@ -32,9 +32,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -70,9 +68,9 @@ fun ConcordCreateScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var name by remember { mutableStateOf("") }
var about by remember { mutableStateOf("") }
var iconUrl by remember { mutableStateOf("") }
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val iconUrl = remember { mutableStateOf("") }
val relays = remember { mutableListOf<NormalizedRelayUrl>().toMutableStateList() }
var working by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -96,31 +94,20 @@ fun ConcordCreateScreen(
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_name)) },
)
OutlinedTextField(
value = about,
onValueChange = { about = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_about)) },
)
OutlinedTextField(
value = iconUrl,
onValueChange = { iconUrl = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_icon)) },
placeholder = { Text("https://…/icon.png") },
ConcordMetadataFields(
name = name,
about = about,
iconUrl = iconUrl,
robotSeed = "concord-new",
accountViewModel = accountViewModel,
)
SectionHeader(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays))
ConcordSectionHeader(
title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays),
description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc),
)
relays.forEach { relay ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
@@ -138,21 +125,21 @@ fun ConcordCreateScreen(
Button(
onClick = {
if (name.isBlank() || working) return@Button
if (name.value.isBlank() || working) return@Button
working = true
scope.launch {
val communityId =
accountViewModel.account.createConcordCommunity(
name = name.trim(),
description = about.trim().ifBlank { null },
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
relays = relays.map { it.url },
icon = iconUrl.trim().ifBlank { null },
icon = iconUrl.value.trim().ifBlank { null },
)
working = false
if (communityId != null) nav.newStack(Route.ConcordServer(communityId))
}
},
enabled = name.isNotBlank() && !working,
enabled = name.value.isNotBlank() && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action))
@@ -161,15 +148,25 @@ fun ConcordCreateScreen(
}
}
/** A section header (title + one-line description) matching the NIP-29 metadata form. */
@Composable
private fun SectionHeader(text: String) {
Surface(color = MaterialTheme.colorScheme.surface) {
fun ConcordSectionHeader(
title: String,
description: String? = null,
) {
Column(Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp),
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
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.stringRes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* Edit a Concord community's metadata (name / description / icon). Reuses the shared
* [ConcordMetadataFields] hero + fields, prefilled from the folded Control Plane, and
* saves a new metadata edition via [com.vitorpamplona.amethyst.model.Account.editConcordMetadata]
* honored on fold only when this account holds MANAGE_METADATA (or is the owner).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordEditScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val iconUrl = remember { mutableStateOf("") }
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.
LaunchedEffect(state?.metadata) {
val md = state?.metadata
if (!prefilled && md != null) {
name.value = md.name
about.value = md.description.orEmpty()
iconUrl.value = md.icon.orEmpty()
prefilled = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.concord_edit_title), fontWeight = FontWeight.Bold, maxLines = 1) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
if (session == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ConcordMetadataFields(
name = name,
about = about,
iconUrl = iconUrl,
robotSeed = communityId,
accountViewModel = accountViewModel,
)
Button(
onClick = {
if (name.value.isBlank() || working) return@Button
working = true
scope.launch {
val ok =
account.editConcordMetadata(
communityId = communityId,
name = name.value.trim(),
description = about.value.trim().ifBlank { null },
icon = iconUrl.value.trim().ifBlank { null },
relays = state?.metadata?.relays ?: session.entry.relays,
)
working = false
if (ok) nav.popBack()
}
},
enabled = name.value.isNotBlank() && !working,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(stringRes(R.string.concord_edit_save))
}
}
}
}
@@ -0,0 +1,246 @@
/*
* 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.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordMembership
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The members roster of one Concord community the analog of NIP-29's
* `RelayGroupMembersScreen`. Concord has no relay-signed roster (membership is key
* possession), so this shows the *privileged* roster derivable from the folded
* Control Plane: the owner, every role-holder (admins/moderators), and banned
* users. The overflow menu offers promote/demote (owner) and ban/unban, gated on
* the viewer's authority exactly as the write path enforces on fold.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcordMembersScreen(
communityId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) }
val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle()
val myPubKey = account.signer.pubKey
val roster =
remember(state) {
val s = state ?: return@remember emptyList<RosterEntry>()
val authority = s.authority
val pubkeys = (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers()).map { it.lowercase() }.distinct()
pubkeys
.map { RosterEntry(it, ConcordMembership.of(authority, it)) }
.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringRes(R.string.concord_members_title), fontWeight = FontWeight.Bold, maxLines = 1)
state?.metadata?.name?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
) { padding ->
if (roster.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(R.string.concord_members_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(roster, key = { it.pubkey }) { entry ->
ConcordMemberRow(
entry = entry,
communityId = communityId,
isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true),
viewerIsOwner = iAmOwner,
viewerCanBan = iCanBan,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
@Composable
private fun ConcordMemberRow(
entry: RosterEntry,
communityId: String,
isSelf: Boolean,
viewerIsOwner: Boolean,
viewerCanBan: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) }
val isOwnerTarget = entry.membership == ConcordMembership.OWNER
val isBanned = entry.membership == ConcordMembership.BANNED
val isAdmin = entry.membership == ConcordMembership.ADMIN
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders,
// never against the owner or yourself. A banned user only offers "unban".
val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf
val canBan = viewerCanBan && !isOwnerTarget && !isSelf
val hasMenu = canToggleAdmin || canBan
androidx.compose.foundation.layout.Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
if (user != null) {
UsernameDisplay(user, accountViewModel = accountViewModel)
} else {
Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
MemberBadge(entry.membership)
if (hasMenu) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (canToggleAdmin) {
DropdownMenuItem(
text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) },
onClick = {
accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin)
expanded = false
},
)
}
if (canBan) {
DropdownMenuItem(
text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) },
onClick = {
accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned)
expanded = false
},
)
}
}
}
}
}
/** A small pill labelling the member's standing (owner / admin / banned; plain members render nothing). */
@Composable
private fun MemberBadge(membership: ConcordMembership) {
val label =
when (membership) {
ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner)
ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin)
ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned)
else -> return
}
val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer
val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
Surface(shape = RoundedCornerShape(6.dp), color = container) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = content,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
private class RosterEntry(
val pubkey: HexKey,
val membership: ConcordMembership,
)
/** Owner first, then admins, then plain members, then banned last. */
private fun ConcordMembership.sortRank(): Int =
when (this) {
ConcordMembership.OWNER -> 0
ConcordMembership.ADMIN -> 1
ConcordMembership.MEMBER -> 2
ConcordMembership.NONE -> 3
ConcordMembership.BANNED -> 4
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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 com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* The shared metadata form for creating and editing a Concord community a large
* circular icon preview at the top that reflects the icon URL live (tap it to jump
* to the URL field), then the name, description, and icon-URL fields. Mirrors the
* NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features
* feel consistent. Callers own the state and add the surrounding scaffold, relays
* section (create only), and the create/save action.
*/
@Composable
fun ConcordMetadataFields(
name: MutableState<String>,
about: MutableState<String>,
iconUrl: MutableState<String>,
robotSeed: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val iconFocus = remember { FocusRequester() }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ConcordIconHero(
robotSeed = robotSeed,
iconUrl = iconUrl.value,
displayName = name.value,
accountViewModel = accountViewModel,
onClick = { iconFocus.requestFocus() },
)
OutlinedTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringRes(R.string.concord_create_name)) },
)
OutlinedTextField(
value = about.value,
onValueChange = { about.value = it },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 5,
label = { Text(stringRes(R.string.concord_create_about)) },
)
OutlinedTextField(
value = iconUrl.value,
onValueChange = { iconUrl.value = it },
modifier = Modifier.fillMaxWidth().focusRequester(iconFocus),
singleLine = true,
label = { Text(stringRes(R.string.concord_create_icon)) },
placeholder = { Text("https://…/icon.png") },
)
}
}
/** The circular community-icon hero: shows the icon URL live over a stable robohash placeholder. */
@Composable
private fun ConcordIconHero(
robotSeed: String,
iconUrl: String,
displayName: String,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier =
Modifier
.size(104.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = iconUrl.ifBlank { null },
contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) },
modifier = Modifier.size(104.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
}
Text(
text = stringRes(R.string.concord_create_icon_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(top = 8.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
+15
View File
@@ -327,6 +327,21 @@
<string name="concord_ban_user">Ban</string>
<string name="concord_ban_user_title">Ban from this community?</string>
<string name="concord_ban_user_body">This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later.</string>
<string name="concord_create_icon_hint">Set a community icon</string>
<string name="concord_create_relays_desc">Relays that store this community\'s encrypted messages. Leave empty to use your own.</string>
<string name="concord_edit_title">Edit community</string>
<string name="concord_edit_save">Save</string>
<string name="concord_members_title">Members</string>
<string name="concord_members_empty">No owner, admins, or banned members to show yet.</string>
<string name="concord_members_make_admin">Make admin</string>
<string name="concord_members_remove_admin">Remove admin</string>
<string name="concord_members_ban">Ban</string>
<string name="concord_members_unban">Unban</string>
<string name="concord_role_owner">Owner</string>
<string name="concord_role_admin">Admin</string>
<string name="concord_role_banned">Banned</string>
<string name="concord_invite_card_join">Join community</string>
<string name="concord_invite_card_subtitle">Concord community invite</string>
<string name="chats_history_proto_nip17">encrypted</string>
<string name="chats_history_proto_nip04">legacy</string>
<string name="chats_reply_searching_history">Looking for the original message…</string>
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
@@ -96,6 +97,26 @@ object ConcordModeration {
return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, 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
* the next version onto the metadata head. Honored at fold only when [actor]
* holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation].
*/
suspend fun editMetadata(
actor: NostrSigner,
controlPlane: GroupKey,
communityId: ByteArray,
metadata: MetadataEntity,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
): Event {
val (version, prev) = versioning(current, ControlEntityKind.METADATA, communityId)
val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata)
return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation)
}
/** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */
suspend fun grant(
actor: NostrSigner,
@@ -55,6 +55,17 @@ class AuthorityResolver private constructor(
/** The role ids a member currently holds (empty for the owner and for plain members). */
fun rolesOf(pubKey: String): Set<String> = memberRoles[pubKey.lowercase()] ?: emptySet()
/**
* The set of pubkeys that hold at least one validly-granted role (lowercase
* hex). This is the *privileged* roster admins/moderators and any other
* role-holders and excludes the owner and silent key-holding members, since
* plain membership is key possession and leaves no Control-Plane trace.
*/
fun roleHolders(): Set<String> = memberRoles.keys
/** The healed banlist union (lowercase hex). */
fun bannedMembers(): Set<String> = banned
/** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */
fun rank(pubKey: String): Long? {
val m = pubKey.lowercase()