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 3803c251e3..9e925eab69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + ): 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f9fc50973c..d58c8ad814 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { + ConcordMembersScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordEditScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordInviteScreen( link = it.link, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 89b424cc7d..b4ab7953bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index aaa2558356..23f5b595d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, 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 5fc7ff6aff..d75ce8185a 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 @@ -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 = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 621e692d9e..d8c680f857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -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().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, + ) + } } } 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 new file mode 100644 index 0000000000..3fe74570ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt new file mode 100644 index 0000000000..5ff00e5e4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -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() + 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 + } 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 new file mode 100644 index 0000000000..fb712c7079 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -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, + about: MutableState, + iconUrl: MutableState, + 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), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d3b7d8eda3..38613b8987 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -327,6 +327,21 @@ Ban Ban from this community? 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. + Set a community icon + Relays that store this community\'s encrypted messages. Leave empty to use your own. + Edit community + Save + Members + No owner, admins, or banned members to show yet. + Make admin + Remove admin + Ban + Unban + Owner + Admin + Banned + Join community + Concord community invite encrypted legacy Looking for the original message… 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 b576928541..c38f3858a4 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 @@ -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, + 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index e5ed18453d..efefab9211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -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 = 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 = memberRoles.keys + + /** The healed banlist union (lowercase hex). */ + fun bannedMembers(): Set = banned + /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ fun rank(pubKey: String): Long? { val m = pubKey.lowercase()