feat(buzz): add people to a channel/community + mint invite links

Three owner/admin add-people paths (the relay enforces the role gate):

- Channel add-member: an "Add member" FAB on the channel members screen
  opens a user-search dialog that publishes a NIP-29 kind-9000 put-user for
  the picked pubkey (plain member).
- Community add-member: an "Add people to this workspace" action in the Buzz
  community view publishes the Buzz relay-admin add-member command (kind
  9030) so the relay updates its NIP-43 membership list (13534). Wires the
  existing quartz RelayAdminAddMember/RemoveMember events into Account +
  AccountViewModel (addCommunityMember / removeCommunityMember).
- Invite-link mint: BuzzInviteMinter does the Buzz-specific, NIP-98-signed
  POST /api/invites on the relay host (via HTTPAuthorizationEvent + the
  trusted-relay OkHttp client) and returns {code, url, expires_at}; a
  "Create invite link" button surfaces the link with Copy / Share.

The user search is extracted into a reusable BuzzAddPeopleDialog shared by
the channel and community add flows. /api/invites is Buzz-proprietary (only
NIP-98 is standard); community add uses Buzz's 9030 admin command, since
NIP-43 itself has no owner-initiated add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-23 15:48:50 +00:00
parent a9fcd1c19c
commit 20384f5481
8 changed files with 506 additions and 0 deletions
@@ -158,6 +158,8 @@ import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
@@ -3148,6 +3150,28 @@ class Account(
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
* [relay] with no channel scope.
*/
suspend fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
}
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
suspend fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) {
signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
@@ -0,0 +1,101 @@
/*
* 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.service.buzz
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Mints a `block/buzz` workspace invite link by POSTing to the relay's **Buzz-specific**
* `/api/invites` HTTP endpoint (NIP-98 signed; owner/admin only). Not a Nostr event and not a NIP
* only the NIP-98 auth is standard so this is Buzz-only and lives outside quartz.
*
* The endpoint host is the relay's own host (the invite link is `https://<host>/invite/<code>`), so
* we never need to own a domain the relay does, and it checks the signer is an owner/admin. See
* `crates/buzz-relay/src/api/invites.rs`.
*/
object BuzzInviteMinter {
private val json = ObjectMapper()
/** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */
data class MintedInvite(
val code: String,
val url: String,
val expiresAt: Long,
)
/**
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d];
* default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient]
* supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is
* reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure.
*/
suspend fun mint(
relay: NormalizedRelayUrl,
ttlSecs: Long?,
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
): MintedInvite =
withContext(Dispatchers.IO) {
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
val wsUrl = relay.url
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
val host = wsUrl.substringAfter("://").substringBefore("/")
val url = "$scheme://$host/api/invites"
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}"
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
val auth = httpAuth(url, "POST", bodyBytes)
val request =
Request
.Builder()
.url(url)
.addHeader("Authorization", auth.toAuthToken())
.post(bodyBytes.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient(url).newCall(request).execute().use { response ->
val payload = response.body?.string().orEmpty()
val tree = runCatching { json.readTree(payload) }.getOrNull()
if (!response.isSuccessful) {
val slug = tree?.get("error")?.asText() ?: "HTTP ${response.code}"
throw IllegalStateException(slug)
}
MintedInvite(
code = tree?.get("code")?.asText().orEmpty(),
url = tree?.get("url")?.asText().orEmpty(),
expiresAt = tree?.get("expires_at")?.asLong() ?: 0L,
)
}
}
}
@@ -1684,6 +1684,19 @@ class AccountViewModel(
roles: List<String>,
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
/** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */
fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) = launchSigner { account.addCommunityMember(relay, pubkey, role) }
/** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */
fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
@@ -0,0 +1,163 @@
/*
* 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.buzz
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
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.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* A reusable "add a person" dialog: a typeahead over the local user cache (name / NIP-05 / npub
* prefix, or a pasted npub/hex). Tapping a result that isn't already in the target invokes [onAdd];
* members already present are shown with an "Added" hint and aren't tappable.
*
* Context-agnostic the caller supplies [isAlreadyIn] (membership predicate) and [onAdd] (the
* actual add, e.g. a channel kind-9000 put-user or a community kind-9030 admin-add). Used by both
* the channel members screen and the Buzz community view.
*/
@Composable
fun BuzzAddPeopleDialog(
title: String,
accountViewModel: AccountViewModel,
nav: INav,
isAlreadyIn: (HexKey) -> Boolean,
onAdd: (HexKey) -> Unit,
onDismiss: () -> Unit,
) {
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<HexKey>>(emptyList()) }
LaunchedEffect(query) {
if (query.isBlank()) {
results = emptyList()
return@LaunchedEffect
}
delay(150)
results =
withContext(Dispatchers.IO) {
LocalCache
.findUsersStartingWith(query.trim(), accountViewModel.account)
.map { it.pubkeyHex }
.take(15)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
)
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
items(results, key = { it }) { hex ->
val alreadyIn = isAlreadyIn(hex)
AddPersonRow(hex, alreadyIn, accountViewModel, nav) {
if (!alreadyIn) {
onAdd(hex)
onDismiss()
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
)
}
@Composable
private fun AddPersonRow(
hex: HexKey,
alreadyIn: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !alreadyIn, onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
UserPicture(hex, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f)) {
if (user != null) {
UsernameDisplay(user, accountViewModel = accountViewModel)
} else {
Text(hex.take(8), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (alreadyIn) {
Text(
text = stringRes(R.string.buzz_import_added),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,143 @@
/*
* 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.buzz
import android.content.Intent
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.buzz.BuzzInviteMinter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/**
* A "Create invite link" text button for a Buzz workspace owner/admin: mints a link via the relay's
* `/api/invites` endpoint ([BuzzInviteMinter]) and shows it with Copy / Share. Any member sees the
* button, but the relay only serves owners/admins a rejection surfaces as the error dialog.
*/
@Composable
fun BuzzInviteMintButton(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
) {
var minting by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<BuzzInviteMinter.MintedInvite?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
TextButton(
onClick = {
if (minting) return@TextButton
minting = true
error = null
scope.launch {
try {
result =
BuzzInviteMinter.mint(
relay = relay,
ttlSecs = null,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForPushRegistration,
httpAuth = accountViewModel.account::createHTTPAuthorization,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: e::class.simpleName
} finally {
minting = false
}
}
},
) {
if (minting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Link, contentDescription = null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.buzz_invite_create))
}
result?.let { minted ->
AlertDialog(
onDismissRequest = { result = null },
title = { Text(stringRes(R.string.buzz_invite_link_title)) },
text = {
SelectionContainer {
Text(minted.url, style = MaterialTheme.typography.bodyMedium)
}
},
confirmButton = {
TextButton(onClick = {
val send =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, minted.url)
}
ContextCompat.startActivity(context, Intent.createChooser(send, null), null)
result = null
}) { Text(stringRes(R.string.buzz_invite_share)) }
},
dismissButton = {
TextButton(onClick = {
clipboard.setText(AnnotatedString(minted.url))
result = null
}) { Text(stringRes(R.string.buzz_invite_copy)) }
},
)
}
error?.let { message ->
AlertDialog(
onDismissRequest = { error = null },
title = { Text(stringRes(R.string.buzz_invite_error_title)) },
text = { Text(message, color = MaterialTheme.colorScheme.error) },
confirmButton = {
TextButton(onClick = { error = null }) { Text(stringRes(R.string.buzz_invite_dismiss)) }
},
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.tor.TorType
@@ -82,8 +84,10 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteMintButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
@@ -233,6 +237,11 @@ fun RelayGroupChannelListScreen(
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
}
// Community add-member (kind-9030). Offered to members of a Buzz workspace; the relay enforces
// the owner/admin requirement (a non-admin's command is simply rejected), since our NIP-43
// roster read drops roles and can't gate precisely.
var showAddPeople by remember { mutableStateOf(false) }
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
@@ -311,6 +320,17 @@ fun RelayGroupChannelListScreen(
}
if (isBuzz) {
item(key = "add-people") {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { showAddPeople = true }) {
Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.buzz_community_add_people))
}
BuzzInviteMintButton(relay = relay, accountViewModel = accountViewModel)
}
}
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
if (noChannelsYet) {
item(key = "buzz-no-channels") {
@@ -441,6 +461,17 @@ fun RelayGroupChannelListScreen(
}
}
}
if (showAddPeople) {
BuzzAddPeopleDialog(
title = stringRes(R.string.buzz_community_add_people),
accountViewModel = accountViewModel,
nav = nav,
isAlreadyIn = { BuzzCommunityMembership.isMember(relay, it) },
onAdd = { accountViewModel.addCommunityMember(relay, it) },
onDismiss = { showAddPeople = false },
)
}
}
/** A first screen's worth of a community's DMs shown inline; the rest live behind the See-all row. */
@@ -38,6 +38,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack
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.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
@@ -163,6 +165,8 @@ private fun RelayGroupMembers(
.sortedBy { it.membership.rank() }
}
var showAddMember by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
@@ -186,6 +190,14 @@ private fun RelayGroupMembers(
popBack = nav::popBack,
)
},
// Only a moderator can add a member (the relay rejects a kind-9000 from anyone else).
floatingActionButton = {
if (iCanModerate) {
FloatingActionButton(onClick = { showAddMember = true }) {
Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member))
}
}
},
) { padding ->
if (roster.isEmpty()) {
// The roster always has at least the group's admins once it loads, so an
@@ -212,6 +224,17 @@ private fun RelayGroupMembers(
}
}
}
if (showAddMember) {
BuzzAddPeopleDialog(
title = stringRes(R.string.relay_group_add_member),
accountViewModel = accountViewModel,
nav = nav,
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) },
onDismiss = { showAddMember = false },
)
}
}
/** Sort key so admins float above moderators above plain members. */
+8
View File
@@ -3367,6 +3367,14 @@
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="buzz_agent_working">Working…</string>
<string name="relay_group_add_member">Add member</string>
<string name="buzz_community_add_people">Add people to this workspace</string>
<string name="buzz_invite_create">Create invite link</string>
<string name="buzz_invite_link_title">Invite link</string>
<string name="buzz_invite_share">Share</string>
<string name="buzz_invite_copy">Copy</string>
<string name="buzz_invite_error_title">Couldn\'t create invite</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_star">Star channel</string>
<string name="buzz_unstar">Unstar channel</string>
<string name="buzz_dm_section_empty">No conversations yet</string>