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 4de3614b1c..fa940f597b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -168,6 +169,10 @@ import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot @@ -3302,8 +3307,21 @@ class Account( hashtags: List = emptyList(), geohashes: List = emptyList(), parent: String? = null, + channelType: String? = null, ): GroupId { - signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } + // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes + // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without + // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay + // publish two events and produce nothing at all. + signAndSendPrivatelyOrBroadcast( + CreateGroupEvent.build( + groupId = groupId, + name = name, + about = about, + visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, + channelType = channelType, + ), + ) { listOf(relay) } val edit = EditMetadataEvent.build( @@ -3413,7 +3431,19 @@ class Account( pubkey: HexKey, roles: List, ) { - val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles)) + // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its + // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails + // the whole put-user, which is why an unmapped role must become `member` rather than travel. + val buzzRole = + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + when { + roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN + else -> BUZZ_ROLE_MEMBER + } + } else { + null + } + val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index d6d8925c93..269095c8c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -172,7 +172,11 @@ fun SlimListItem( supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + // The container default stays `background` — what this layout has always painted — rather than + // ListItemDefaults' `surface`, so existing callers are unchanged. It is a parameter now because + // the row was painting its own opaque background even when the caller asked for another colour: + // inside a container that already has a surface (a dialog) that reads as a black block. + colors: ListItemColors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background), tonalElevation: Dp = ListItemContainerElevation, shadowElevation: Dp = ListItemContainerElevation, ) { @@ -232,7 +236,7 @@ fun SlimListItem( Surface( modifier = Modifier.semantics(mergeDescendants = true) {}.then(modifier), shape = ListItemDefaults.shape, - color = MaterialTheme.colorScheme.background, + color = colors.containerColor, contentColor = MaterialTheme.colorScheme.onBackground, tonalElevation = tonalElevation, shadowElevation = shadowElevation, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index dc8a448dce..ccd20a5bf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -32,6 +32,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -65,6 +67,9 @@ import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +/** The dropdown's breathing room under the composer it floats over. */ +private val SuggestionListPadding = PaddingValues(top = 10.dp) + @Composable fun ShowUserSuggestionList( userSuggestions: UserSuggestionState, @@ -73,6 +78,13 @@ fun ShowUserSuggestionList( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + // Defaults suit this list's usual home: a dropdown floating over a composer, where an opaque + // row and a divider per entry are what separate it from the text underneath. Inside a container + // that already provides its own surface — a dialog — that chrome reads as a black box bolted on, + // so those callers pass a transparent row and drop the dividers. + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { UserSearchDataSourceSubscription(userSuggestions, accountViewModel) @@ -93,7 +105,7 @@ fun ShowUserSuggestionList( } } - WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent) + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent, itemColors, showDividers, contentPadding) } @Composable @@ -117,6 +129,9 @@ fun WatchResponses( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) @@ -125,7 +140,7 @@ fun WatchResponses( val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } LazyColumn( - contentPadding = PaddingValues(top = 10.dp), + contentPadding = contentPadding, modifier = modifier, state = listState, ) { @@ -137,10 +152,12 @@ fun WatchResponses( } else { null } - UserLine(item, accountViewModel, trailing) { onSelect(item) } - HorizontalDivider( - thickness = DividerThickness, - ) + UserLine(item, accountViewModel, trailing, itemColors) { onSelect(item) } + if (showDividers) { + HorizontalDivider( + thickness = DividerThickness, + ) + } } } } else { @@ -169,9 +186,11 @@ fun UserLine( baseUser: User, accountViewModel: AccountViewModel, trailingContent: (@Composable (User) -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), onClick: () -> Unit, ) { SlimListItem( + colors = colors, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), leadingContent = { ClickableUserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, onClick = null) 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 index c07df3d3d6..bcaf03c8d6 100644 --- 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 @@ -20,80 +20,71 @@ */ 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.PaddingValues 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.ListItemDefaults 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +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.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.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import androidx.compose.runtime.LaunchedEffect as ComposeLaunchedEffect /** - * 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. + * A reusable "add a person" dialog for Buzz: the app's ordinary user search — the same + * [UserSuggestionState] engine the @-mention typeahead uses — over the local cache, the relays + * (NIP-50) and NIP-05 identifiers, plus a pasted npub/nprofile. + * + * It used to search only [com.vitorpamplona.amethyst.model.LocalCache], so anyone the device had + * never seen simply had no result and the only way through was to paste a raw hex key — which is + * what the field's own hint told you to do. Searching the relays is what makes finding a person by + * name work at all here. * * 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. + * the channel members screen and the Buzz community view. Members already present render an "Added" + * hint instead of the add affordance and do nothing when tapped. */ @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 + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, Amethyst.instance.nip05Client) } - delay(150) - results = - withContext(Dispatchers.IO) { - LocalCache - .findUsersStartingWith(query.trim(), accountViewModel.account) - .map { it.pubkeyHex } - .take(15) - } - } + val focusRequester = remember { FocusRequester() } + + ComposeLaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + ComposeLaunchedEffect(Unit) { focusRequester.requestFocus() } AlertDialog( onDismissRequest = onDismiss, @@ -103,21 +94,61 @@ fun BuzzAddPeopleDialog( OutlinedTextField( value = query, onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), singleLine = true, - leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) }, - label = { Text(stringRes(R.string.buzz_dm_add_hint)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringRes(R.string.buzz_add_people_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) + + // The typeahead needs a couple of characters before a relay search is worth firing; + // below that the list would flash every match in the cache. + if (query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (!isAlreadyIn(user.pubkeyHex)) { + onAdd(user.pubkeyHex) onDismiss() } - } - } + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + // The dialog already supplies the surface and the spacing: drop the + // dropdown's opaque rows, per-row dividers and top gap, which are there for + // floating over a composer. + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + onEmpty = { + Text( + text = stringRes(R.string.buzz_add_people_empty), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { user -> + if (isAlreadyIn(user.pubkeyHex)) { + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.relay_group_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) } } }, @@ -125,39 +156,3 @@ fun BuzzAddPeopleDialog( 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/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index adf62d63be..616c8b57ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -138,6 +138,18 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> + // Advance the room's last-read marker for whatever this row turns out to be. This used + // to live inside NormalChatNote — the `else` of the branch below — so a row rendered by + // any of the specialised paths (Buzz system lines and activity rows, diffs, forum votes, + // NIP-28 admin lines, zaps) never marked itself read. A channel whose newest events are + // system messages therefore kept its unread badge no matter how often it was opened, + // which on a Buzz relay is most channels: joins and role changes are system messages. + if (routeForLastRead != null) { + LaunchedEffect(key1 = routeForLastRead, key2 = baseNote.idHex) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt(), dismissNotificationId = baseNote.idHex) + } + } + val event = baseNote.event if (event is LnZapEvent) { RenderChatZap(baseNote, accountViewModel, nav) @@ -163,7 +175,6 @@ fun ChatroomMessageCompose( } else { NormalChatNote( baseNote, - routeForLastRead, innerQuote, canPreview, parentBackgroundColor, @@ -194,7 +205,6 @@ fun ChatroomMessageCompose( @Composable fun NormalChatNote( note: Note, - routeForLastRead: String?, innerQuote: Boolean = false, canPreview: Boolean = true, parentBackgroundColor: MutableState? = null, @@ -222,12 +232,6 @@ fun NormalChatNote( } } - if (routeForLastRead != null) { - LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt(), dismissNotificationId = note.idHex) - } - } - // A geohash chat asks own messages to still show the author line (which identity posted), so the // usual "hide the name on my own bubbles" shortcut is opt-out there. val showSelfAuthorName = LocalChatShowSelfAuthorName.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index d2a7b390be..57b146653b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -290,62 +290,66 @@ private fun ConcordMemberRow( MemberBadge(entry.membership, entry.roleName) 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 - }, - ) + // One Box for button + menu: an expanded DropdownMenu emits a node, and as a direct child + // of this `spacedBy` Row that adds a gap and shifts the button as you tap it. + Box { + IconButton(onClick = { expanded = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) } - if (viewerCanManageRoles) { - DropdownMenuItem( - text = { - Column { - Text(stringRes(R.string.concord_members_roles)) - rolesBlockedReason?.let { - Text( - it, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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 (viewerCanManageRoles) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.concord_members_roles)) + rolesBlockedReason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - enabled = rolesBlockedReason == null, - onClick = { - editRoles = true - 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 - }, - ) - } - if (canRemove) { - DropdownMenuItem( - text = { - Text( - stringRes(R.string.concord_members_remove), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - confirmRemove = true - expanded = false - }, - ) + }, + enabled = rolesBlockedReason == null, + onClick = { + editRoles = true + 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 + }, + ) + } + if (canRemove) { + DropdownMenuItem( + text = { + Text( + stringRes(R.string.concord_members_remove), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + confirmRemove = true + expanded = false + }, + ) + } } } } 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 8feb4ef174..fed4acca93 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 @@ -314,7 +314,8 @@ fun RelayGroupChannelListScreen( FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { Icon( symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.relay_group_create_title), + contentDescription = + stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), modifier = Modifier.size(24.dp), ) } @@ -487,7 +488,6 @@ fun RelayGroupChannelListScreen( 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 }, 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 175b2e71e8..7c2101d041 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 @@ -25,9 +25,12 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -38,10 +41,10 @@ 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 +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -60,6 +63,7 @@ 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.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -73,12 +77,14 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState 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 import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow @@ -165,8 +171,6 @@ private fun RelayGroupMembers( .sortedBy { it.membership.rank() } } - var showAddMember by remember { mutableStateOf(false) } - Scaffold( topBar = { TopBarExtensibleWithBackButton( @@ -191,11 +195,17 @@ private fun RelayGroupMembers( ) }, // Only a moderator can add a member (the relay rejects a kind-9000 from anyone else). - floatingActionButton = { + // The search sits at the bottom of the screen rather than behind a button: adding people is + // usually adding *several*, and a dialog made that "open, search, pick, dialog closes, + // reopen" per person. Inline, each pick lands in the roster above while the field keeps + // focus for the next name. + bottomBar = { if (iCanModerate) { - FloatingActionButton(onClick = { showAddMember = true }) { - Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member)) - } + AddMemberBar( + isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, + onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, + accountViewModel = accountViewModel, + ) } }, ) { padding -> @@ -224,15 +234,83 @@ 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 }, +/** + * The always-present "add a member" search docked at the bottom of the roster: the app's ordinary + * user typeahead (local cache + relay + NIP-05 + a pasted npub), with its results rising above the + * field the way a chat composer's suggestions do. + * + * Picking someone adds them and clears the query but keeps the keyboard, so a moderator can add a + * handful of people in one pass. Someone already in the group shows an "Added" hint instead of the + * add icon and does nothing when tapped — the relay would reject the duplicate anyway. + */ +@Composable +private fun AddMemberBar( + isAlreadyIn: (HexKey) -> Boolean, + onAdd: (HexKey) -> Unit, + accountViewModel: AccountViewModel, +) { + var query by remember { mutableStateOf("") } + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, Amethyst.instance.nip05Client) + } + + LaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + + // Docked at the bottom, so it has to clear the gesture bar and ride above the keyboard — + // otherwise the field it is meant to be typed into is the part that gets covered. + Column( + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding(), + ) { + if (query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (!isAlreadyIn(user.pubkeyHex)) { + onAdd(user.pubkeyHex) + query = "" + } + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + contentPadding = PaddingValues(0.dp), + trailingContent = { user -> + if (isAlreadyIn(user.pubkeyHex)) { + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.relay_group_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + singleLine = true, + leadingIcon = { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringRes(R.string.buzz_add_people_hint)) }, ) } } @@ -255,6 +333,8 @@ private fun RelayGroupMemberRow( accountViewModel: AccountViewModel, nav: INav, ) { + val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + // Create-or-get (never a one-shot null): UsernameDisplay observes the user's // metadata flow, so the name fills in when the kind:0 arrives instead of being // stuck on truncated hex forever. @@ -301,88 +381,96 @@ private fun RelayGroupMemberRow( (viewerIsAdmin || entry.membership != RelayGroupMembership.ADMIN) if (canActOnTarget) { - IconButton(onClick = { menuOpen = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - val declaredRoles = channel.supportedRoles - if (declaredRoles.isNotEmpty()) { - // The relay declares its own role set (kind 39003) — offer exactly those - // instead of the built-in admin/moderator pair. Roles are privilege grants, - // so only admins assign them; the relay is the final authority. - if (viewerIsAdmin) { - declaredRoles.forEach { role -> - val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } - if (!alreadyHasRole) { - DropdownMenuItem( - text = { - Column { - Text(stringRes(R.string.relay_group_assign_role, role.name)) - role.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Button and menu share one Box: an expanded DropdownMenu still emits a node into its + // parent, so as a direct child of this `spacedBy` Row it added a second 12.dp gap and + // visibly nudged the button sideways the moment you tapped it. + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + val declaredRoles = channel.supportedRoles + if (declaredRoles.isNotEmpty()) { + // The relay declares its own role set (kind 39003) — offer exactly those + // instead of the built-in admin/moderator pair. Roles are privilege grants, + // so only admins assign them; the relay is the final authority. + if (viewerIsAdmin) { + declaredRoles.forEach { role -> + val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } + if (!alreadyHasRole) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.relay_group_assign_role, role.name)) + role.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - onClick = { - menuOpen = false - // Additive: NIP-29 allows multiple roles per member and the menu only - // offers roles they lack, so keep the ones they already hold. - accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) - }, - ) + }, + onClick = { + menuOpen = false + // Additive: NIP-29 allows multiple roles per member and the menu only + // offers roles they lack, so keep the ones they already hold. + accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) + }, + ) + } } } + } else { + // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. + if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_admin)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + }, + ) + } + // Buzz's roles are owner/admin/member/guest/bot — there is no moderator, and a + // role it cannot parse fails the whole put-user. Offering it there is offering a + // menu item that cannot do anything. + if (!isBuzzRelay && entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_moderator)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) + }, + ) + } } - } else { - // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. - if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_admin)) }, + text = { Text(stringRes(R.string.relay_group_demote_member)) }, onClick = { menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList()) }, ) } - if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_moderator)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) - }, - ) - } - } - if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_demote_member)) }, + text = { + Text( + text = stringRes(R.string.relay_group_remove_user), + color = MaterialTheme.colorScheme.error, + ) + }, onClick = { menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList()) + confirmRemove = true }, ) } - DropdownMenuItem( - text = { - Text( - text = stringRes(R.string.relay_group_remove_user), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - menuOpen = false - confirmRemove = true - }, - ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 0618c5188a..c71641b669 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -198,8 +198,8 @@ private fun RelayGroupMetadataScaffold( topBar = { if (viewModel.isNewGroup) { CreatingTopBar( - titleRes = R.string.relay_group_create_title, - isActive = { viewModel.canPost && nip29Support == true }, + titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, ) @@ -226,20 +226,23 @@ private fun RelayGroupMetadataScaffold( .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), ) { - if (nip29Support == false) { + if (nip29Support == false && !viewModel.isBuzzRelay) { NoNip29Warning() Spacer(Modifier.height(16.dp)) } - GroupImagePicker(viewModel) { wantsToPickImage = true } - - Spacer(Modifier.height(16.dp)) + if (!viewModel.isBuzzRelay) { + GroupImagePicker(viewModel) { wantsToPickImage = true } + Spacer(Modifier.height(16.dp)) + } GroupMetadataFields(viewModel) - Spacer(Modifier.height(16.dp)) - - ParentGroupSection(viewModel, accountViewModel) + // Sub-groups are a NIP-29 relation; Buzz has no parent channel. + if (!viewModel.isBuzzRelay) { + Spacer(Modifier.height(16.dp)) + ParentGroupSection(viewModel, accountViewModel) + } } } } @@ -356,32 +359,38 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) - Spacer(Modifier.height(12.dp)) - Text( - text = stringRes(R.string.relay_group_section_discovery), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(R.string.relay_group_section_discovery_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) + // Everything below is NIP-29 vocabulary. A Buzz relay stores only `name`, `about` and a + // two-valued `visibility` (its 9002 handler accepts nothing else), so offering hashtags, a + // geohash, or the invite-only/restricted/hidden flags there would be four controls that look + // like they configure the channel and are silently dropped by the relay. + if (!viewModel.isBuzzRelay) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringRes(R.string.relay_group_section_discovery), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.relay_group_section_discovery_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) - OutlinedTextField( - value = viewModel.topics.value, - onValueChange = { - viewModel.topics.value = it - viewModel.markTouched() - }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_field_topics)) }, - placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - Spacer(Modifier.height(8.dp)) - GroupLocationField(viewModel) + OutlinedTextField( + value = viewModel.topics.value, + onValueChange = { + viewModel.topics.value = it + viewModel.markTouched() + }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_field_topics)) }, + placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Spacer(Modifier.height(8.dp)) + GroupLocationField(viewModel) + } Spacer(Modifier.height(12.dp)) Text( @@ -390,14 +399,33 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { color = MaterialTheme.colorScheme.primary, ) + // Buzz's `visibility`: open = searchable and anyone may join, private = hidden and invite-only. + // One switch covers both, so it keeps NIP-29's private flag but says what Buzz actually does. LabeledSwitchRow( - label = stringRes(R.string.relay_group_flag_private), - description = stringRes(R.string.relay_group_flag_private_desc), + label = stringRes(if (viewModel.isBuzzRelay) R.string.buzz_channel_flag_private else R.string.relay_group_flag_private), + description = stringRes(if (viewModel.isBuzzRelay) R.string.buzz_channel_flag_private_desc else R.string.relay_group_flag_private_desc), checked = viewModel.isPrivate, ) { viewModel.isPrivate = it viewModel.markTouched() } + + if (viewModel.isBuzzRelay) { + // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its + // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. + if (viewModel.isNewGroup) { + LabeledSwitchRow( + label = stringRes(R.string.buzz_channel_flag_forum), + description = stringRes(R.string.buzz_channel_flag_forum_desc), + checked = viewModel.isForum, + ) { + viewModel.isForum = it + viewModel.markTouched() + } + } + return + } + LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only), description = stringRes(R.string.relay_group_flag_invite_only_desc), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index db8e535d49..f4519f4bcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException @@ -43,6 +44,9 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -65,9 +69,26 @@ class RelayGroupMetadataViewModel : ViewModel() { private var channel: RelayGroupChannel? = null val isNewGroup by derivedStateOf { channel == null } - /** Host relay (create + edit) and the group id (generated in create mode). */ - var relay: NormalizedRelayUrl? = null + /** + * Host relay (create + edit) and the group id (generated in create mode). + * + * Snapshot state rather than a plain var: it is assigned by initCreate/initEdit *after* the + * first composition and [isBuzzRelay] derives from it, so a plain var would leave the screen + * rendering its NIP-29 shape forever. + */ + var relay: NormalizedRelayUrl? by mutableStateOf(null) private set + + /** + * True when the target relay speaks the Buzz dialect. Buzz calls these **channels**, and honours + * only a subset of NIP-29's metadata: `name`, `about` and a two-valued `visibility`. Its create + * path also takes a `channel_type`, which is what [isForum] selects. + */ + val isBuzzRelay by derivedStateOf { relay?.let { BuzzRelayDialect.isBuzz(it) } == true } + + /** Buzz only: create a `forum` channel (threaded posts) instead of a `stream` (chat) one. */ + var isForum by mutableStateOf(false) + var groupId: String = "" private set @@ -120,8 +141,9 @@ class RelayGroupMetadataViewModel : ViewModel() { this.account = accountViewModel.account if (this.relay == null) { this.relay = relay - // Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada). - this.groupId = RandomInstance.bytes(8).toHexKey() + // Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada) — except on a + // Buzz relay, which keys channels by UUID and ignores an id it cannot parse as one. + this.groupId = if (BuzzRelayDialect.isBuzz(relay)) newBuzzChannelId() else RandomInstance.bytes(8).toHexKey() } } @@ -245,6 +267,7 @@ class RelayGroupMetadataViewModel : ViewModel() { hashtags = hashtags, geohashes = geohashes, parent = parentGroupId, + channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null, ) } else { account.editRelayGroupMetadata( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 3422b2b296..9c1a2b3e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -96,6 +96,35 @@ val RELAY_GROUP_METADATA_KINDS = */ val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND) +/** + * A live, channel-scoped subscription to this group's own relay-signed state (39000-39003) on a Buzz + * relay — the thing that keeps a roster, a name or a visibility flip current without a refetch. + * + * Buzz signs these with `d`/`p` tags and **no `h`**, so a `#h` filter looks like it could not match. + * It does: `filter_match_one` falls back to the stored `channel_id` for an `#h` filter **when the + * event carries no `h` tag at all**, and these are stored channel-scoped. Scoping the filter by `#h` + * is also what indexes the subscription under the channel, which is what makes it eligible for the + * channel fan-out in the first place — a `#d` filter has no channel tag, so on Buzz it registers as a + * global subscription and by design receives no channel-scoped event, which is why these updates + * never arrived live. + * + * Buzz-only. On a relay29-family relay the same events are addressable with no `channel_id` behind + * them, so an `#h` filter matches nothing there — those relays keep being served by the `#d` + * directory filters. + */ +fun buildRelayGroupLiveStateFilter(groupId: GroupId): List = + listOf( + RelayBasedFilter( + relay = groupId.relayUrl, + filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + ), + // Pins stay in their own filter for the same reason the directory filters split them out. + RelayBasedFilter( + relay = groupId.relayUrl, + filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + ), + ) + /** * Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable * events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index f0279dca67..66cd9bdad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager @@ -117,6 +118,12 @@ class RelayGroupJoinedChatTailSubAssembler( // Reactions/deletions for every message in this channel — the shape Buzz's own client uses. buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()), ) + + // This group's own state (39000-39003 + pins), `#h`-scoped so Buzz streams it. The + // account-wide state subscription asks by `#d`, which carries no channel tag and so + // registers as a global subscription there — and Buzz never fans a channel-scoped event + // to one of those, which is why a rename or a role change used to sit stale until the + // next cold start. Costs nothing on a relay29 relay, where it simply matches nothing. + (if (BuzzRelayDialect.isBuzz(relay)) buildRelayGroupLiveStateFilter(key.groupId) else emptyList()) + filterGroupNotificationsToPubkey( relay = relay, pubkey = key.account.userProfile().pubkeyHex, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0253dfbb19..d0f92a98bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2541,6 +2541,12 @@ Ephemeral chats Lightweight, relay-scoped rooms that don\'t persist history. Create a group + + New channel + Private channel + Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. + Forum channel + Threaded posts instead of a chat timeline. This cannot be changed later. This relay doesn\'t advertise support for NIP-29 relay groups. A group created here won\'t work — its name, members and messages won\'t be managed by the relay. Pick a relay that supports relay-based groups. Group name Topic (optional) @@ -3533,6 +3539,8 @@ Workspace To Add someone (npub or hex) + Search by name, NIP-05 or npub + No one found. Try a different name, a NIP-05 address, or paste an npub. Start conversation Opening… Remove diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 8b72c3d6f6..11c5bb23bd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -301,7 +301,7 @@ class RelayGroupChannel( val admin = admins.firstOrNull { it.pubKey == pubkey } if (admin != null) { return when { - admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN + admin.roles.any { role -> RelayGroupMembership.ADMIN_ROLES.any { role.equals(it, true) } } -> RelayGroupMembership.ADMIN // Presence in the kind-39001 admins list IS the moderation signal; // the role labels (moderator, ceo, owner, …) are relay-defined. So // anyone in that list who isn't the top-level admin is at least a diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt index 8a4e55ca33..aeeedc4c36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt @@ -56,5 +56,15 @@ enum class RelayGroupMembership { companion object { const val ROLE_ADMIN = "admin" const val ROLE_MODERATOR = "moderator" + + /** + * Buzz's top role. Its hierarchy is `owner` > `admin` > `member` (no moderator), so the + * channel's creator carries `owner` and never the literal `admin` — which used to leave the + * one person with full authority classified below it, unable to promote anyone. + */ + const val ROLE_OWNER = "owner" + + /** Role strings that mean full authority over the group, across both dialects. */ + val ADMIN_ROLES = listOf(ROLE_ADMIN, ROLE_OWNER) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt index 2ebfc6c31c..676ba91902 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.buzz.workspace import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.utils.RandomInstance /* * Buzz-specific readers over a NIP-29 group's relay-signed metadata (kind:39000). @@ -51,3 +53,41 @@ fun GroupMetadataEvent.buzzParticipants(): List = tags.mapNotNull(PTag:: const val BUZZ_CHANNEL_TYPE_DM = "dm" const val BUZZ_CHANNEL_TYPE_FORUM = "forum" const val BUZZ_CHANNEL_TYPE_STREAM = "stream" + +/** + * Buzz's two channel visibilities, from `crates/buzz-db/src/channel.rs`: [BUZZ_VISIBILITY_OPEN] is + * searchable and anyone may join, [BUZZ_VISIBILITY_PRIVATE] is hidden and invite-only. They ride a + * `visibility` tag on the create (9007) and metadata (9002) events — NIP-29's own `private` status + * flag is a separate vocabulary the Buzz relay does not read. + */ +const val BUZZ_VISIBILITY_OPEN = "open" +const val BUZZ_VISIBILITY_PRIVATE = "private" + +/** + * A new Buzz channel id: a RFC-4122 v4 UUID string. + * + * Buzz keys channels by UUID and parses the create event's `h` tag with `val.parse::()` + * (`extract_h_tag_channel`). NIP-29's own convention — a short random hex id — does not parse, so + * the relay silently ignores the id the client chose and creates the channel under one of its own. + * The client then subscribes to an id the relay never used: the new channel shows an empty feed and + * never gets a name. Group ids are opaque strings in NIP-29, so a UUID is valid there too. + */ +fun newBuzzChannelId(): String { + val bytes = RandomInstance.bytes(16) + // v4, RFC-4122 variant. + bytes[6] = ((bytes[6].toInt() and 0x0F) or 0x40).toByte() + bytes[8] = ((bytes[8].toInt() and 0x3F) or 0x80).toByte() + val hex = bytes.toHexKey() + return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}" +} + +/** + * Buzz's channel member roles, from `crates/buzz-core/src/channel.rs`. Note there is **no + * moderator**: a role string outside this set fails the relay's put-user handler outright + * (`invalid role: …`), taking the membership change with it. + */ +const val BUZZ_ROLE_OWNER = "owner" +const val BUZZ_ROLE_ADMIN = "admin" +const val BUZZ_ROLE_MEMBER = "member" +const val BUZZ_ROLE_GUEST = "guest" +const val BUZZ_ROLE_BOT = "bot" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt index 0dbed7662e..20bc7e50eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,15 +39,39 @@ class CreateGroupEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = tags.groupId() + fun name() = tags.firstTagValue("name") + companion object { const val KIND = 9007 + /** + * NIP-29 create-group. The spec carries only the group id here and leaves the metadata to a + * following kind-9002, which is what plain relay29 expects. + * + * Buzz is stricter: `ingest.rs` rejects a 9007 **before storage** with + * `invalid: channel name is required` unless the create event itself carries a `name` tag, + * and reads `about` / `visibility` / `channel_type` off the same event. A create without + * them is dropped outright, so the 9002 that follows addresses a channel that was never + * made — the group simply never appears. + * + * Sending the metadata on both events satisfies both: relay29 ignores the extra tags and + * takes the 9002, Buzz takes the 9007. [visibility] (`open` / `private`) and [channelType] + * (`stream` / `forum` / …) are Buzz's vocabulary and are omitted unless given. + */ fun build( groupId: String, + name: String? = null, + about: String? = null, + visibility: String? = null, + channelType: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) + name?.takeIf { it.isNotBlank() }?.let { add(arrayOf("name", it)) } + about?.takeIf { it.isNotBlank() }?.let { add(arrayOf("about", it)) } + visibility?.let { add(arrayOf("visibility", it)) } + channelType?.let { add(arrayOf("channel_type", it)) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt index b6b9e48b9f..d2f9382413 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt @@ -45,10 +45,22 @@ class PutUserEvent( companion object { const val KIND = 9000 + /** + * NIP-29 put-user. The roles ride inside each `p` tag (`["p", pubkey, role, …]`), which is + * what relay29 reads. + * + * [buzzRole] additionally emits a top-level `["role", …]` tag. Buzz reads **only** that — + * `extract_tag_value(event, "role")`, defaulting to `member` — so without it every put-user + * lands as a plain member and a promotion silently does nothing. Its vocabulary is also its + * own (`owner`/`admin`/`member`/`guest`/`bot`, no moderator); an unparseable role fails the + * whole handler, so callers map to Buzz's set before passing it here. Harmless on relay29, + * which ignores the extra tag. + */ fun build( groupId: String, pubKeysWithRoles: List>>, previousEvents: List = emptyList(), + buzzRole: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -56,6 +68,7 @@ class PutUserEvent( pubKeysWithRoles.forEach { (pubKey, roles) -> userPubKeyWithRoles(pubKey, roles) } + buzzRole?.let { add(arrayOf("role", it)) } previous(previousEvents) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt new file mode 100644 index 0000000000..8593da9d7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt @@ -0,0 +1,95 @@ +/* + * 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.quartz.buzz.workspace + +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The two things a Buzz relay requires of a create (kind-9007) that plain NIP-29 does not. Both + * failed silently — the relay either rejected the event outright or created the channel under an id + * the client never used — so "create a group" published two events and produced nothing the user + * could see. + */ +class BuzzChannelCreateTest { + @Test + fun createCarriesTheMetadataBuzzReadsOffTheCreateEvent() { + // `ingest.rs` rejects a 9007 pre-storage with "invalid: channel name is required" unless the + // create event itself names the channel; NIP-29 alone would leave this to the 9002. + val tpl = + CreateGroupEvent.build( + groupId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + name = "design", + about = "where design happens", + visibility = BUZZ_VISIBILITY_PRIVATE, + channelType = BUZZ_CHANNEL_TYPE_FORUM, + ) + + assertEquals(CreateGroupEvent.KIND, tpl.kind) + assertEquals("design", tpl.tags.firstTagValue("name")) + assertEquals("where design happens", tpl.tags.firstTagValue("about")) + assertEquals("private", tpl.tags.firstTagValue("visibility")) + assertEquals("forum", tpl.tags.firstTagValue("channel_type")) + assertEquals("3f2504e0-4f89-41d3-9a0c-0305e82c3301", tpl.tags.firstTagValue("h")) + } + + /** A plain NIP-29 create stays exactly as the spec has it: the id and nothing else. */ + @Test + fun createWithoutBuzzMetadataIsUnchanged() { + val tpl = CreateGroupEvent.build(groupId = "abc123") + + assertEquals("abc123", tpl.tags.firstTagValue("h")) + assertNull(tpl.tags.firstTagValue("name")) + assertNull(tpl.tags.firstTagValue("visibility")) + assertNull(tpl.tags.firstTagValue("channel_type")) + } + + /** Blank input is omitted rather than sent as an empty tag, which the relay rejects the same way. */ + @Test + fun blankMetadataIsOmitted() { + val tpl = CreateGroupEvent.build(groupId = "abc123", name = " ", about = "") + + assertNull(tpl.tags.firstTagValue("name")) + assertNull(tpl.tags.firstTagValue("about")) + } + + /** + * Buzz keys channels by UUID and parses the `h` tag with `val.parse::()`. A NIP-29-style + * 16-char hex id does not parse, so the relay ignores the client's id and creates the channel + * under one of its own — leaving the app subscribed to an id that does not exist, which is what + * made a freshly created channel open on an empty feed with a hex id for a title. + */ + @Test + fun newChannelIdIsAParseableV4Uuid() { + val id = newBuzzChannelId() + + assertEquals(36, id.length) + assertEquals(listOf(8, 4, 4, 4, 12), id.split("-").map { it.length }) + assertTrue(id.all { it.isDigit() || it in 'a'..'f' || it == '-' }, "lowercase hex + dashes only: $id") + assertEquals('4', id[14], "version nibble must say v4") + assertTrue(id[19] in "89ab", "variant nibble must be RFC-4122: ${id[19]}") + assertTrue(newBuzzChannelId() != newBuzzChannelId(), "ids must not repeat") + } +}