From 7cc53dc8ba487739896f2bce5c668354c4f5598f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 20:40:53 +0000 Subject: [PATCH 1/2] fix: pop Create Group screen when navigating to new Marmot group After creating a Marmot group, the Create Group screen stays on the back stack, so pressing back from the new group chat returns to the empty creation form instead of the Messages screen. Swap `nav.nav` for `nav.popUpTo(..., Route.CreateMarmotGroup::class)` so the creation screen is removed inclusively as we navigate into the group. --- .../ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 2c390e93a..7178a3e3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -80,7 +80,7 @@ fun CreateGroupScreen( name = groupName.trim(), description = "", ) - nav.nav(Route.MarmotGroupChat(nostrGroupId)) + nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class) } catch (e: Exception) { isCreating = false launch(Dispatchers.Main) { From 119959e942a7830f523bdf6b0a0f7e411a9c630e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:16:25 +0000 Subject: [PATCH 2/2] 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 --- .../userSuggestions/ShowUserSuggestionList.kt | 8 +- .../marmotGroup/MarmotGroupInfoScreen.kt | 282 +++++++++--------- 2 files changed, 151 insertions(+), 139 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 7a0ae4ef3..1dbf06030 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -67,6 +67,7 @@ fun ShowUserSuggestionList( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, + trailingContent: (@Composable (User) -> Unit)? = null, ) { 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 @@ -110,6 +111,7 @@ fun WatchResponses( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, + trailingContent: (@Composable (User) -> Unit)? = null, ) { val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) @@ -120,7 +122,7 @@ fun WatchResponses( state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel) { onSelect(item) } + UserLine(item, accountViewModel, trailingContent) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -135,6 +137,7 @@ fun WatchResponses( fun UserLine( baseUser: User, accountViewModel: AccountViewModel, + trailingContent: (@Composable (User) -> Unit)? = null, onClick: () -> Unit, ) { SlimListItem( @@ -153,6 +156,7 @@ fun UserLine( WatchAndDisplayNip05Row(baseUser, accountViewModel) } }, + trailingContent = trailingContent?.let { { it(baseUser) } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 3ef3f350f..517ec7336 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -32,10 +32,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.size 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.ExitToApp import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -165,129 +165,128 @@ fun MarmotGroupInfoScreen( ) }, ) { padding -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .imePadding(), ) { - // Group header section - item { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { + // Group header section (fixed at top) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName ?: "Group ${nostrGroupId.take(8)}...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + if (!groupDescription.isNullOrEmpty()) { Text( - text = displayName ?: "Group ${nostrGroupId.take(8)}...", - 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", + text = groupDescription!!, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), ) } - if (groupRelays.isNotEmpty()) { - GroupRelayStrip( - relayUrls = groupRelays, - relayActivity = relayActivity, - accountViewModel = accountViewModel, - nav = nav, - ) - } + Text( + text = "${members.size} members", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + 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 - item { - AddMemberInline( - nostrGroupId = nostrGroupId, - searchInput = addSearchInput, - onSearchInputChange = { value -> - addSearchInput = value - if (!isAddError) addStatus = null - if (value.length > 2) { - userSuggestions.processCurrentWord(value) - } else { - userSuggestions.reset() - } - }, - userSuggestions = userSuggestions, - statusMessage = addStatus, - isError = isAddError, - isAdding = isAdding, - accountViewModel = accountViewModel, - onAdd = { user -> - isAdding = true - isAddError = false - addStatus = "Adding ${user.toBestDisplayName()}..." - val targetPubkey = user.pubkeyHex - val targetName = user.toBestDisplayName() - scope.launch(Dispatchers.IO) { - try { - val result = accountViewModel.addMarmotGroupMember(nostrGroupId, targetPubkey) - if (result.startsWith("Success")) { - addStatus = null - isAddError = false - addSearchInput = "" - userSuggestions.reset() - } else { - addStatus = "Failed to add $targetName: ${result.removePrefix("Error: ")}" - isAddError = true - } - } catch (e: Exception) { - addStatus = "Failed to add $targetName: ${e.message ?: "unknown error"}" + HorizontalDivider() + + // Suggestions + add-member input (fixed at bottom) + AddMemberInline( + nostrGroupId = nostrGroupId, + searchInput = addSearchInput, + onSearchInputChange = { value -> + addSearchInput = value + if (!isAddError) addStatus = null + if (value.length > 2) { + userSuggestions.processCurrentWord(value) + } else { + userSuggestions.reset() + } + }, + userSuggestions = userSuggestions, + statusMessage = addStatus, + isError = isAddError, + isAdding = isAdding, + accountViewModel = accountViewModel, + onAdd = { user -> + isAdding = true + isAddError = false + addStatus = "Adding ${user.toBestDisplayName()}..." + val targetPubkey = user.pubkeyHex + val targetName = user.toBestDisplayName() + scope.launch(Dispatchers.IO) { + try { + val result = accountViewModel.addMarmotGroupMember(nostrGroupId, targetPubkey) + if (result.startsWith("Success")) { + addStatus = null + isAddError = false + addSearchInput = "" + userSuggestions.reset() + } else { + addStatus = "Failed to add $targetName: ${result.removePrefix("Error: ")}" 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, ) { Column(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = searchInput, - onValueChange = onSearchInputChange, - label = { Text("Add member") }, - placeholder = { Text("Name, npub, or NIP-05") }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - singleLine = true, - enabled = !isAdding, - ) + if (!isAdding && searchInput.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> onAdd(user) }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + onEmpty = { + Text( + "They must have published a KeyPackage (kind:30443) to be added.", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + 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) { Text( @@ -390,23 +403,18 @@ private fun AddMemberInline( ) } - if (!isAdding && searchInput.length > 2) { - Spacer(modifier = Modifier.height(4.dp)) - ShowUserSuggestionList( - userSuggestions = userSuggestions, - onSelect = { user -> onAdd(user) }, - accountViewModel = accountViewModel, - modifier = SuggestionListDefaultHeightChat, - onEmpty = { - Text( - "They must have published a KeyPackage (kind:30443) to be added.", - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } + OutlinedTextField( + value = searchInput, + onValueChange = onSearchInputChange, + label = { Text("Add member") }, + placeholder = { Text("Name, npub, or NIP-05") }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + enabled = !isAdding, + ) } }