mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
+25
-6
@@ -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)
|
||||
|
||||
+78
-83
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -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
|
||||
|
||||
+58
-54
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+170
-82
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-36
@@ -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),
|
||||
|
||||
+27
-4
@@ -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(
|
||||
|
||||
+29
@@ -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
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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"
|
||||
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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()
|
||||
}
|
||||
|
||||
+95
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user