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:
+6
-2
@@ -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) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-137
@@ -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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user