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 02c6b2c2a8..a8ba363c3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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). * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/buzz/BuzzInviteMinter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/buzz/BuzzInviteMinter.kt new file mode 100644 index 0000000000..7e103168a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/buzz/BuzzInviteMinter.kt @@ -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:///invite/`), 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, + ) + } + } +} 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 b4fc77f1af..082b3435ac 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 @@ -1684,6 +1684,19 @@ class AccountViewModel( roles: List, ) = 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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt new file mode 100644 index 0000000000..c07df3d3d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt @@ -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>(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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteMintButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteMintButton.kt new file mode 100644 index 0000000000..57503b76b1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteMintButton.kt @@ -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(null) } + var error by remember { mutableStateOf(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)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c6303d99ea..c3b07f2f0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 0da1e79370..368234417c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -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. */ diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2c39b5bece..a3b10775f1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3367,6 +3367,14 @@ Channels Forums Working… + Add member + Add people to this workspace + Create invite link + Invite link + Share + Copy + Couldn\'t create invite + OK Star channel Unstar channel No conversations yet