mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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.
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
+15
@@ -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 = {
|
||||
|
||||
+34
-37
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+153
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -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
|
||||
}
|
||||
+153
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user