feat: pin add-member field to bottom of Marmots group info with add button

Move the add-member search to a fixed position at the bottom of the screen
(matching the New Short Note pattern) so it stays visible regardless of how
far the user has scrolled through a large member list. Suggestions now render
above the input, and each suggestion row shows an explicit PersonAdd icon
button so the add action has a clear visual affordance.

Adds an optional trailingContent slot to ShowUserSuggestionList / UserLine
so callers can attach per-row actions without forking the component.

https://claude.ai/code/session_01JbRycm4g3LrqatnCVWVdGh
This commit is contained in:
Claude
2026-04-22 21:16:25 +00:00
parent c82f4342c4
commit 119959e942
2 changed files with 151 additions and 139 deletions
@@ -67,6 +67,7 @@ fun ShowUserSuggestionList(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEmpty: @Composable () -> Unit = {}, onEmpty: @Composable () -> Unit = {},
trailingContent: (@Composable (User) -> Unit)? = null,
) { ) {
UserSearchDataSourceSubscription(userSuggestions, accountViewModel) UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
@@ -87,7 +88,7 @@ fun ShowUserSuggestionList(
} }
} }
WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty) WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent)
} }
@Composable @Composable
@@ -110,6 +111,7 @@ fun WatchResponses(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onEmpty: @Composable () -> Unit = {}, onEmpty: @Composable () -> Unit = {},
trailingContent: (@Composable (User) -> Unit)? = null,
) { ) {
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
@@ -120,7 +122,7 @@ fun WatchResponses(
state = listState, state = listState,
) { ) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item ->
UserLine(item, accountViewModel) { onSelect(item) } UserLine(item, accountViewModel, trailingContent) { onSelect(item) }
HorizontalDivider( HorizontalDivider(
thickness = DividerThickness, thickness = DividerThickness,
) )
@@ -135,6 +137,7 @@ fun WatchResponses(
fun UserLine( fun UserLine(
baseUser: User, baseUser: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
trailingContent: (@Composable (User) -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
SlimListItem( SlimListItem(
@@ -153,6 +156,7 @@ fun UserLine(
WatchAndDisplayNip05Row(baseUser, accountViewModel) WatchAndDisplayNip05Row(baseUser, accountViewModel)
} }
}, },
trailingContent = trailingContent?.let { { it(baseUser) } },
) )
} }
@@ -32,10 +32,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -45,6 +44,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -165,129 +165,128 @@ fun MarmotGroupInfoScreen(
) )
}, },
) { padding -> ) { padding ->
LazyColumn( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
.imePadding(),
) { ) {
// Group header section // Group header section (fixed at top)
item { Column(
Column( modifier =
modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .padding(16.dp),
.padding(16.dp), ) {
) { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.weight(1f)) { Text(
text = displayName ?: "Group ${nostrGroupId.take(8)}...",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
if (!groupDescription.isNullOrEmpty()) {
Text( Text(
text = displayName ?: "Group ${nostrGroupId.take(8)}...", text = groupDescription!!,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
if (!groupDescription.isNullOrEmpty()) {
Text(
text = groupDescription!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Text(
text = "${members.size} members",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
if (groupRelays.isNotEmpty()) { Text(
GroupRelayStrip( text = "${members.size} members",
relayUrls = groupRelays, style = MaterialTheme.typography.bodyMedium,
relayActivity = relayActivity, color = MaterialTheme.colorScheme.onSurfaceVariant,
accountViewModel = accountViewModel, modifier = Modifier.padding(top = 4.dp),
nav = nav, )
) }
} if (groupRelays.isNotEmpty()) {
GroupRelayStrip(
relayUrls = groupRelays,
relayActivity = relayActivity,
accountViewModel = accountViewModel,
nav = nav,
)
} }
} }
HorizontalDivider() }
HorizontalDivider()
// Members list (scrollable, takes remaining vertical space)
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
item {
Text(
text = "Members",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
items(members, key = { it.leafIndex }) { member ->
val isMe = member.pubkey == myPubkey
val isAdmin = member.pubkey in adminPubkeys
MemberRow(
member = member,
isMe = isMe,
isAdmin = isAdmin,
canRemove = !isMe,
accountViewModel = accountViewModel,
nav = nav,
onRemoveClick = { memberToRemove = member },
)
HorizontalDivider()
}
} }
// Inline add-member search HorizontalDivider()
item {
AddMemberInline( // Suggestions + add-member input (fixed at bottom)
nostrGroupId = nostrGroupId, AddMemberInline(
searchInput = addSearchInput, nostrGroupId = nostrGroupId,
onSearchInputChange = { value -> searchInput = addSearchInput,
addSearchInput = value onSearchInputChange = { value ->
if (!isAddError) addStatus = null addSearchInput = value
if (value.length > 2) { if (!isAddError) addStatus = null
userSuggestions.processCurrentWord(value) if (value.length > 2) {
} else { userSuggestions.processCurrentWord(value)
userSuggestions.reset() } else {
} userSuggestions.reset()
}, }
userSuggestions = userSuggestions, },
statusMessage = addStatus, userSuggestions = userSuggestions,
isError = isAddError, statusMessage = addStatus,
isAdding = isAdding, isError = isAddError,
accountViewModel = accountViewModel, isAdding = isAdding,
onAdd = { user -> accountViewModel = accountViewModel,
isAdding = true onAdd = { user ->
isAddError = false isAdding = true
addStatus = "Adding ${user.toBestDisplayName()}..." isAddError = false
val targetPubkey = user.pubkeyHex addStatus = "Adding ${user.toBestDisplayName()}..."
val targetName = user.toBestDisplayName() val targetPubkey = user.pubkeyHex
scope.launch(Dispatchers.IO) { val targetName = user.toBestDisplayName()
try { scope.launch(Dispatchers.IO) {
val result = accountViewModel.addMarmotGroupMember(nostrGroupId, targetPubkey) try {
if (result.startsWith("Success")) { val result = accountViewModel.addMarmotGroupMember(nostrGroupId, targetPubkey)
addStatus = null if (result.startsWith("Success")) {
isAddError = false addStatus = null
addSearchInput = "" isAddError = false
userSuggestions.reset() addSearchInput = ""
} else { userSuggestions.reset()
addStatus = "Failed to add $targetName: ${result.removePrefix("Error: ")}" } else {
isAddError = true addStatus = "Failed to add $targetName: ${result.removePrefix("Error: ")}"
}
} catch (e: Exception) {
addStatus = "Failed to add $targetName: ${e.message ?: "unknown error"}"
isAddError = true isAddError = true
} finally {
isAdding = false
} }
} catch (e: Exception) {
addStatus = "Failed to add $targetName: ${e.message ?: "unknown error"}"
isAddError = true
} finally {
isAdding = false
} }
}, }
) },
HorizontalDivider() )
}
// Members section header
item {
Text(
text = "Members",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
// Member list with inline remove button
items(members, key = { it.leafIndex }) { member ->
val isMe = member.pubkey == myPubkey
val isAdmin = member.pubkey in adminPubkeys
MemberRow(
member = member,
isMe = isMe,
isAdmin = isAdmin,
canRemove = !isMe,
accountViewModel = accountViewModel,
nav = nav,
onRemoveClick = { memberToRemove = member },
)
HorizontalDivider()
}
} }
} }
@@ -363,18 +362,32 @@ private fun AddMemberInline(
onAdd: (com.vitorpamplona.amethyst.model.User) -> Unit, onAdd: (com.vitorpamplona.amethyst.model.User) -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( if (!isAdding && searchInput.length > 2) {
value = searchInput, ShowUserSuggestionList(
onValueChange = onSearchInputChange, userSuggestions = userSuggestions,
label = { Text("Add member") }, onSelect = { user -> onAdd(user) },
placeholder = { Text("Name, npub, or NIP-05") }, accountViewModel = accountViewModel,
modifier = modifier = SuggestionListDefaultHeightChat,
Modifier onEmpty = {
.fillMaxWidth() Text(
.padding(horizontal = 16.dp, vertical = 8.dp), "They must have published a KeyPackage (kind:30443) to be added.",
singleLine = true, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
enabled = !isAdding, style = MaterialTheme.typography.bodySmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = { user ->
IconButton(onClick = { onAdd(user) }) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = "Add to group",
tint = MaterialTheme.colorScheme.primary,
)
}
},
)
HorizontalDivider()
}
if (statusMessage != null) { if (statusMessage != null) {
Text( Text(
@@ -390,23 +403,18 @@ private fun AddMemberInline(
) )
} }
if (!isAdding && searchInput.length > 2) { OutlinedTextField(
Spacer(modifier = Modifier.height(4.dp)) value = searchInput,
ShowUserSuggestionList( onValueChange = onSearchInputChange,
userSuggestions = userSuggestions, label = { Text("Add member") },
onSelect = { user -> onAdd(user) }, placeholder = { Text("Name, npub, or NIP-05") },
accountViewModel = accountViewModel, modifier =
modifier = SuggestionListDefaultHeightChat, Modifier
onEmpty = { .fillMaxWidth()
Text( .padding(horizontal = 16.dp, vertical = 8.dp),
"They must have published a KeyPackage (kind:30443) to be added.", singleLine = true,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), enabled = !isAdding,
style = MaterialTheme.typography.bodySmall, )
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
} }
} }