Merge pull request #3742 from vitorpamplona/fix/buzz-create-channel

fix(buzz): make channel create, add-member and role changes actually work
This commit is contained in:
Vitor Pamplona
2026-07-27 13:16:32 -04:00
committed by GitHub
19 changed files with 702 additions and 280 deletions
@@ -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<String> = emptyList(),
geohashes: List<String> = 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<String>,
) {
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() }
}
@@ -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,
@@ -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)
@@ -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<List<HexKey>>(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,
)
}
}
}
@@ -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<Color>? = 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
@@ -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
},
)
}
}
}
}
@@ -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 },
@@ -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
},
)
}
}
}
@@ -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),
@@ -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(
@@ -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<RelayBasedFilter> =
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
@@ -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,
+8
View File
@@ -2541,6 +2541,12 @@
<string name="chat_type_ephemeral_title">Ephemeral chats</string>
<string name="chat_type_ephemeral_desc">Lightweight, relay-scoped rooms that don\'t persist history.</string>
<string name="relay_group_create_title">Create a group</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_create_title">New channel</string>
<string name="buzz_channel_flag_private">Private channel</string>
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
<string name="buzz_channel_flag_forum">Forum channel</string>
<string name="buzz_channel_flag_forum_desc">Threaded posts instead of a chat timeline. This cannot be changed later.</string>
<string name="relay_group_relay_no_nip29">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.</string>
<string name="relay_group_create_name">Group name</string>
<string name="relay_group_create_topic">Topic (optional)</string>
@@ -3533,6 +3539,8 @@
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">To</string>
<string name="buzz_dm_add_hint">Add someone (npub or hex)</string>
<string name="buzz_add_people_hint">Search by name, NIP-05 or npub</string>
<string name="buzz_add_people_empty">No one found. Try a different name, a NIP-05 address, or paste an npub.</string>
<string name="buzz_dm_start">Start conversation</string>
<string name="buzz_dm_opening">Opening…</string>
<string name="buzz_dm_remove">Remove</string>
@@ -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
@@ -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)
}
}
@@ -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<HexKey> = 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::<Uuid>()`
* (`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"
@@ -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<CreateGroupEvent>.() -> 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()
}
}
@@ -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<Pair<HexKey, List<String>>>,
previousEvents: List<String> = emptyList(),
buzzRole: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PutUserEvent>.() -> 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()
}
@@ -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::<Uuid>()`. 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")
}
}