feat: inline member management on Marmot group info screen
- Fold the Remove Member screen into the info screen: each member row now has an inline remove icon that opens a confirmation dialog. - Fold the Add Member screen into the info screen: a search field with a user-suggestion list lives directly above the member list, so adding a member never requires leaving the screen. - Move Leave Group out of the scrolling body into a top-bar action so it is reachable regardless of member count. - Shrink the relay strip (35dp tiles with a small activity dot) and tuck it next to the group header; drop the "Relays" label and the MLS epoch readout, which were visual clutter. - Route the chat screen's Add Member button to the info screen and delete the two standalone screens and their routes. - Add headless interop test 17: A creates a group, adds B, removes B, re-adds B, and verifies B can both receive and send messages. Guards the reported regression where a re-added member came back without a usable leaf and silently lost the ability to post. https://claude.ai/code/session_01JaeqZwVNLKvUUvXWRzPmRP
This commit is contained in:
@@ -75,13 +75,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.AddMemberScreen
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.RemoveMemberScreen
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
||||||
@@ -345,8 +343,6 @@ fun BuildNavigation(
|
|||||||
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
||||||
composableFromBottomArgs<Route.MarmotGroupAddMember> { AddMemberScreen(it.nostrGroupId, accountViewModel, nav) }
|
|
||||||
composableFromBottomArgs<Route.MarmotGroupRemoveMember> { RemoveMemberScreen(it.nostrGroupId, accountViewModel, nav) }
|
|
||||||
composableFromBottomArgs<Route.MarmotGroupEditInfo> { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
composableFromBottomArgs<Route.MarmotGroupEditInfo> { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromEndArgs<Route.PublicChatChannel> {
|
composableFromEndArgs<Route.PublicChatChannel> {
|
||||||
|
|||||||
@@ -370,14 +370,6 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object CreateMarmotGroup : Route()
|
@Serializable object CreateMarmotGroup : Route()
|
||||||
|
|
||||||
@Serializable data class MarmotGroupAddMember(
|
|
||||||
val nostrGroupId: String,
|
|
||||||
) : Route()
|
|
||||||
|
|
||||||
@Serializable data class MarmotGroupRemoveMember(
|
|
||||||
val nostrGroupId: String,
|
|
||||||
) : Route()
|
|
||||||
|
|
||||||
@Serializable data class MarmotGroupEditInfo(
|
@Serializable data class MarmotGroupEditInfo(
|
||||||
val nostrGroupId: String,
|
val nostrGroupId: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|||||||
-306
@@ -1,306 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
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.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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.commons.model.nip05DnsIdentifiers.Nip05State
|
|
||||||
import com.vitorpamplona.amethyst.model.User
|
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar
|
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
|
||||||
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.theme.SuggestionListDefaultHeightPage
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AddMemberScreen(
|
|
||||||
nostrGroupId: HexKey,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: INav,
|
|
||||||
) {
|
|
||||||
var searchInput by remember { mutableStateOf("") }
|
|
||||||
val selectedUsers = remember { mutableStateListOf<User>() }
|
|
||||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isError by remember { mutableStateOf(false) }
|
|
||||||
var isAdding by remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val userSuggestions =
|
|
||||||
remember {
|
|
||||||
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose { userSuggestions.reset() }
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
ActionTopBar(
|
|
||||||
postRes = com.vitorpamplona.amethyst.R.string.add,
|
|
||||||
onCancel = { nav.popBack() },
|
|
||||||
onPost = {
|
|
||||||
if (selectedUsers.isEmpty()) {
|
|
||||||
statusMessage = "No users selected"
|
|
||||||
isError = true
|
|
||||||
return@ActionTopBar
|
|
||||||
}
|
|
||||||
isAdding = true
|
|
||||||
isError = false
|
|
||||||
val usersToAdd = selectedUsers.toList()
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
var successCount = 0
|
|
||||||
val failures = mutableListOf<Pair<User, String>>()
|
|
||||||
for ((index, user) in usersToAdd.withIndex()) {
|
|
||||||
statusMessage =
|
|
||||||
"Adding ${user.toBestDisplayName()} (${index + 1}/${usersToAdd.size})..."
|
|
||||||
isError = false
|
|
||||||
try {
|
|
||||||
val result =
|
|
||||||
accountViewModel.addMarmotGroupMember(
|
|
||||||
nostrGroupId,
|
|
||||||
user.pubkeyHex,
|
|
||||||
)
|
|
||||||
if (result.startsWith("Success")) {
|
|
||||||
successCount++
|
|
||||||
} else {
|
|
||||||
failures.add(user to result.removePrefix("Error: "))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
failures.add(user to (e.message ?: "unknown error"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.isEmpty()) {
|
|
||||||
nav.popBack()
|
|
||||||
} else {
|
|
||||||
statusMessage =
|
|
||||||
buildString {
|
|
||||||
if (successCount > 0) {
|
|
||||||
append("Added $successCount. ")
|
|
||||||
}
|
|
||||||
append("Failed: ")
|
|
||||||
append(
|
|
||||||
failures.joinToString("; ") { (user, reason) ->
|
|
||||||
"${user.toBestDisplayName()} ($reason)"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isError = true
|
|
||||||
// Keep failed users in the list for retry, drop successful ones
|
|
||||||
val failedUsers = failures.map { it.first }.toSet()
|
|
||||||
selectedUsers.clear()
|
|
||||||
selectedUsers.addAll(failedUsers)
|
|
||||||
isAdding = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isActive = { !isAdding && selectedUsers.isNotEmpty() },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(padding)
|
|
||||||
.consumeWindowInsets(padding)
|
|
||||||
.imePadding(),
|
|
||||||
) {
|
|
||||||
// Selected users list
|
|
||||||
if (selectedUsers.isNotEmpty()) {
|
|
||||||
selectedUsers.toList().forEachIndexed { index, user ->
|
|
||||||
SelectedUserRow(
|
|
||||||
user = user,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
enabled = !isAdding,
|
|
||||||
onClear = {
|
|
||||||
selectedUsers.remove(user)
|
|
||||||
statusMessage = null
|
|
||||||
isError = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (index < selectedUsers.lastIndex) {
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search field
|
|
||||||
OutlinedTextField(
|
|
||||||
value = searchInput,
|
|
||||||
onValueChange = { newValue ->
|
|
||||||
searchInput = newValue
|
|
||||||
if (!isError) statusMessage = null
|
|
||||||
if (newValue.length > 2) {
|
|
||||||
userSuggestions.processCurrentWord(newValue)
|
|
||||||
} else {
|
|
||||||
userSuggestions.reset()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("Search users") },
|
|
||||||
placeholder = { Text("Name, npub, or NIP-05") },
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !isAdding,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (statusMessage != null) {
|
|
||||||
Text(
|
|
||||||
text = statusMessage!!,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color =
|
|
||||||
if (isError) {
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
// User suggestion list
|
|
||||||
if (!isAdding && searchInput.length > 2) {
|
|
||||||
ShowUserSuggestionList(
|
|
||||||
userSuggestions = userSuggestions,
|
|
||||||
onSelect = { user ->
|
|
||||||
if (selectedUsers.none { it.pubkeyHex == user.pubkeyHex }) {
|
|
||||||
selectedUsers.add(user)
|
|
||||||
}
|
|
||||||
searchInput = ""
|
|
||||||
userSuggestions.reset()
|
|
||||||
},
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
modifier = SuggestionListDefaultHeightPage,
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectedUserRow(
|
|
||||||
user: User,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: INav,
|
|
||||||
enabled: Boolean,
|
|
||||||
onClear: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
UserPicture(
|
|
||||||
userHex = user.pubkeyHex,
|
|
||||||
size = 40.dp,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = user.toBestDisplayName(),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
UserSecondaryLine(user)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onClear, enabled = enabled) {
|
|
||||||
Text("Remove")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UserSecondaryLine(user: User) {
|
|
||||||
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val text =
|
|
||||||
when (val state = nip05StateMetadata) {
|
|
||||||
is Nip05State.Exists -> {
|
|
||||||
val name = state.nip05.name
|
|
||||||
if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
user.pubkeyDisplayHex()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+1
-1
@@ -86,7 +86,7 @@ fun MarmotGroupChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
IconButton(onClick = { nav.nav(Route.MarmotGroupInfo(nostrGroupId)) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.GroupAdd,
|
imageVector = Icons.Default.GroupAdd,
|
||||||
contentDescription = "Add Member",
|
contentDescription = "Add Member",
|
||||||
|
|||||||
+268
-117
@@ -32,8 +32,10 @@ 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.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
|
||||||
@@ -43,7 +45,6 @@ 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.GroupAdd
|
|
||||||
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
|
||||||
@@ -51,11 +52,13 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -77,9 +80,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
|||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||||
|
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.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||||
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
|
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||||
@@ -108,10 +114,24 @@ fun MarmotGroupInfoScreen(
|
|||||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||||
var isLeaving by remember { mutableStateOf(false) }
|
var isLeaving by remember { mutableStateOf(false) }
|
||||||
|
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
|
||||||
|
var addSearchInput by remember { mutableStateOf("") }
|
||||||
|
var addStatus by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isAddError by remember { mutableStateOf(false) }
|
||||||
|
var isAdding by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val myPubkey = accountViewModel.account.signer.pubKey
|
val myPubkey = accountViewModel.account.signer.pubKey
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val userSuggestions =
|
||||||
|
remember {
|
||||||
|
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { userSuggestions.reset() }
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -131,10 +151,14 @@ fun MarmotGroupInfoScreen(
|
|||||||
contentDescription = "Edit Group Info",
|
contentDescription = "Edit Group Info",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
IconButton(
|
||||||
|
onClick = { showLeaveDialog = true },
|
||||||
|
enabled = !isLeaving,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.GroupAdd,
|
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
contentDescription = "Add Member",
|
contentDescription = "Leave Group",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,46 +179,90 @@ fun MarmotGroupInfoScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = displayName ?: "Group ${nostrGroupId.take(8)}...",
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
Text(
|
||||||
fontWeight = FontWeight.Bold,
|
text = displayName ?: "Group ${nostrGroupId.take(8)}...",
|
||||||
)
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
if (!groupDescription.isNullOrEmpty()) {
|
fontWeight = FontWeight.Bold,
|
||||||
Text(
|
)
|
||||||
text = groupDescription!!,
|
if (!groupDescription.isNullOrEmpty()) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
text = groupDescription!!,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
}
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
Text(
|
)
|
||||||
text = "${members.size} members",
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
text = "${members.size} members",
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
if (groupRelays.isNotEmpty()) {
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
GroupRelayList(
|
)
|
||||||
relayUrls = groupRelays,
|
}
|
||||||
relayActivity = relayActivity,
|
if (groupRelays.isNotEmpty()) {
|
||||||
accountViewModel = accountViewModel,
|
GroupRelayStrip(
|
||||||
nav = nav,
|
relayUrls = groupRelays,
|
||||||
)
|
relayActivity = relayActivity,
|
||||||
}
|
accountViewModel = accountViewModel,
|
||||||
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
|
nav = nav,
|
||||||
if (epoch != null) {
|
)
|
||||||
Text(
|
}
|
||||||
text = "Epoch: $epoch",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
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"}"
|
||||||
|
isAddError = true
|
||||||
|
} finally {
|
||||||
|
isAdding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
// Members section header
|
// Members section header
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
@@ -205,61 +273,21 @@ fun MarmotGroupInfoScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member list
|
// Member list with inline remove button
|
||||||
items(members, key = { it.leafIndex }) { member ->
|
items(members, key = { it.leafIndex }) { member ->
|
||||||
|
val isMe = member.pubkey == myPubkey
|
||||||
|
val isAdmin = member.pubkey in adminPubkeys
|
||||||
MemberRow(
|
MemberRow(
|
||||||
member = member,
|
member = member,
|
||||||
isMe = member.pubkey == myPubkey,
|
isMe = isMe,
|
||||||
isAdmin = member.pubkey in adminPubkeys,
|
isAdmin = isAdmin,
|
||||||
|
canRemove = !isMe,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
|
onRemoveClick = { memberToRemove = member },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group actions section
|
|
||||||
item {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { nav.nav(Route.MarmotGroupRemoveMember(nostrGroupId)) }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PersonRemove,
|
|
||||||
contentDescription = "Remove Member",
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Remove Member",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { showLeaveDialog = true }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
|
||||||
contentDescription = "Leave Group",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Leave Group",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +318,96 @@ fun MarmotGroupInfoScreen(
|
|||||||
onDismiss = { showLeaveDialog = false },
|
onDismiss = { showLeaveDialog = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
memberToRemove?.let { member ->
|
||||||
|
ConfirmRemoveMemberDialog(
|
||||||
|
memberPubkey = member.pubkey,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onConfirm = {
|
||||||
|
memberToRemove = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex)
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
Toast
|
||||||
|
.makeText(context, "Member removed", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
"Failed to remove member: ${e.message}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { memberToRemove = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddMemberInline(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
searchInput: String,
|
||||||
|
onSearchInputChange: (String) -> Unit,
|
||||||
|
userSuggestions: UserSuggestionState,
|
||||||
|
statusMessage: String?,
|
||||||
|
isError: Boolean,
|
||||||
|
isAdding: Boolean,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
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 (statusMessage != null) {
|
||||||
|
Text(
|
||||||
|
text = statusMessage,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color =
|
||||||
|
if (isError) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -297,15 +415,17 @@ fun MemberRow(
|
|||||||
member: GroupMemberInfo,
|
member: GroupMemberInfo,
|
||||||
isMe: Boolean,
|
isMe: Boolean,
|
||||||
isAdmin: Boolean,
|
isAdmin: Boolean,
|
||||||
|
canRemove: Boolean,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
|
onRemoveClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { nav.nav(Route.Profile(member.pubkey)) }
|
.clickable { nav.nav(Route.Profile(member.pubkey)) }
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -332,6 +452,15 @@ fun MemberRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (canRemove) {
|
||||||
|
IconButton(onClick = onRemoveClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonRemove,
|
||||||
|
contentDescription = "Remove Member",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,12 +489,41 @@ fun LeaveGroupDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfirmRemoveMemberDialog(
|
||||||
|
memberPubkey: HexKey,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Remove Member") },
|
||||||
|
text = {
|
||||||
|
LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user ->
|
||||||
|
val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..."
|
||||||
|
Text("Are you sure you want to remove \"$name\" from this group?")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
|
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
|
||||||
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
|
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupRelayList(
|
fun GroupRelayStrip(
|
||||||
relayUrls: List<String>,
|
relayUrls: List<String>,
|
||||||
relayActivity: Map<NormalizedRelayUrl, Long>,
|
relayActivity: Map<NormalizedRelayUrl, Long>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
@@ -378,29 +536,22 @@ fun GroupRelayList(
|
|||||||
|
|
||||||
if (normalized.isEmpty()) return
|
if (normalized.isEmpty()) return
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
FlowRow(
|
||||||
Text(
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
text = "Relays",
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
) {
|
||||||
)
|
val nowSeconds = System.currentTimeMillis() / 1000L
|
||||||
FlowRow(
|
normalized.forEach { (raw, relay) ->
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
val lastSeen = relayActivity[relay]
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
GroupRelayTile(
|
||||||
) {
|
relay = relay,
|
||||||
val nowSeconds = System.currentTimeMillis() / 1000L
|
fallbackUrl = raw,
|
||||||
normalized.forEach { (raw, relay) ->
|
isActive = isActive,
|
||||||
val lastSeen = relayActivity[relay]
|
accountViewModel = accountViewModel,
|
||||||
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
|
nav = nav,
|
||||||
GroupRelayTile(
|
)
|
||||||
relay = relay,
|
|
||||||
fallbackUrl = raw,
|
|
||||||
isActive = isActive,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,7 +572,7 @@ fun GroupRelayTile(
|
|||||||
val clickableModifier =
|
val clickableModifier =
|
||||||
remember(relay) {
|
remember(relay) {
|
||||||
Modifier
|
Modifier
|
||||||
.size(55.dp)
|
.size(35.dp)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
indication = ripple24dp,
|
indication = ripple24dp,
|
||||||
interactionSource = MutableInteractionSource(),
|
interactionSource = MutableInteractionSource(),
|
||||||
@@ -444,7 +595,7 @@ fun GroupRelayTile(
|
|||||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||||
pingInMs = 0,
|
pingInMs = 0,
|
||||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||||
iconModifier = LargeRelayIconModifier,
|
iconModifier = MediumRelayIconModifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
val dotColor =
|
val dotColor =
|
||||||
@@ -458,15 +609,15 @@ fun GroupRelayTile(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.size(12.dp)
|
.size(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.padding(2.dp),
|
.padding(1.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(8.dp)
|
.size(6.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(dotColor),
|
.background(dotColor),
|
||||||
)
|
)
|
||||||
|
|||||||
-250
@@ -1,250 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
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.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.PersonRemove
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
|
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun RemoveMemberScreen(
|
|
||||||
nostrGroupId: HexKey,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: INav,
|
|
||||||
) {
|
|
||||||
val chatroom =
|
|
||||||
remember(nostrGroupId) {
|
|
||||||
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
|
||||||
}
|
|
||||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
|
||||||
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
|
|
||||||
var isRemoving by remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val myPubkey = accountViewModel.account.signer.pubKey
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { nav.popBack() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = { Text("Remove Member") },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
val removableMembers = members.filter { it.pubkey != myPubkey }
|
|
||||||
|
|
||||||
if (removableMembers.isEmpty()) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No members to remove",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "Select a member to remove",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(removableMembers, key = { it.leafIndex }) { member ->
|
|
||||||
RemovableMemberRow(
|
|
||||||
member = member,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
onRemoveClick = { memberToRemove = member },
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (memberToRemove != null) {
|
|
||||||
val member = memberToRemove!!
|
|
||||||
ConfirmRemoveMemberDialog(
|
|
||||||
memberPubkey = member.pubkey,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
onConfirm = {
|
|
||||||
memberToRemove = null
|
|
||||||
isRemoving = true
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex)
|
|
||||||
isRemoving = false
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
Toast
|
|
||||||
.makeText(context, "Member removed", Toast.LENGTH_SHORT)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
nav.popBack()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
isRemoving = false
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
Toast
|
|
||||||
.makeText(
|
|
||||||
context,
|
|
||||||
"Failed to remove member: ${e.message}",
|
|
||||||
Toast.LENGTH_LONG,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = { memberToRemove = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RemovableMemberRow(
|
|
||||||
member: GroupMemberInfo,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
nav: INav,
|
|
||||||
onRemoveClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onRemoveClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
UserPicture(
|
|
||||||
userHex = member.pubkey,
|
|
||||||
size = 36.dp,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user ->
|
|
||||||
Text(
|
|
||||||
text = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}...",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PersonRemove,
|
|
||||||
contentDescription = "Remove",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ConfirmRemoveMemberDialog(
|
|
||||||
memberPubkey: HexKey,
|
|
||||||
accountViewModel: AccountViewModel,
|
|
||||||
onConfirm: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Remove Member") },
|
|
||||||
text = {
|
|
||||||
LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user ->
|
|
||||||
val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..."
|
|
||||||
Text("Are you sure you want to remove \"$name\" from this group?")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onConfirm) {
|
|
||||||
Text("Remove", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -155,6 +155,95 @@ test_08_admin_promote_demote() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test_17_readd_after_remove() {
|
||||||
|
banner "Test 17 — Re-add after remove restores B's leaf"
|
||||||
|
local id="17 re-add after remove"
|
||||||
|
|
||||||
|
# Scenario reported on the info screen: A creates a group, adds B,
|
||||||
|
# removes B, then adds B again. Before the fix B rejoined without a
|
||||||
|
# usable own_leaf, so B could see metadata but could neither decrypt
|
||||||
|
# new posts nor sign messages. This test catches that regression by
|
||||||
|
# driving a full round-trip (A→B and B→A) after the re-add.
|
||||||
|
|
||||||
|
local out gid mls_gid
|
||||||
|
out=$(amy_json marmot group create --name "Interop-17-readd") || {
|
||||||
|
record_result "$id" fail "amy create failed"; return
|
||||||
|
}
|
||||||
|
gid=$(printf '%s' "$out" | jq -r '.group_id')
|
||||||
|
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
|
||||||
|
[[ -n "$gid" && -n "$mls_gid" ]] || {
|
||||||
|
record_result "$id" fail "missing ids from create"; return
|
||||||
|
}
|
||||||
|
|
||||||
|
# A adds B.
|
||||||
|
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
|
||||||
|
record_result "$id" fail "initial add B failed"; return
|
||||||
|
}
|
||||||
|
wait_for_invite B 60 >/dev/null || {
|
||||||
|
record_result "$id" fail "B never received first invite"; return
|
||||||
|
}
|
||||||
|
wn_b groups accept "$mls_gid" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Confirm the baseline works: A → B and B → A both deliver.
|
||||||
|
amy_json marmot message send "$gid" "pre-remove hello" >/dev/null || {
|
||||||
|
record_result "$id" fail "A send before remove failed"; return
|
||||||
|
}
|
||||||
|
wait_for_message B "$mls_gid" "pre-remove hello" 90 || {
|
||||||
|
record_result "$id" fail "B missed pre-remove hello"; return
|
||||||
|
}
|
||||||
|
wn_b messages send "$mls_gid" "pre-remove pong" >/dev/null 2>&1 || true
|
||||||
|
amy_json marmot await message "$gid" --match "pre-remove pong" --timeout 60 >/dev/null || {
|
||||||
|
record_result "$id" fail "A missed B's pre-remove pong"; return
|
||||||
|
}
|
||||||
|
|
||||||
|
# A removes B.
|
||||||
|
amy_json marmot group remove "$gid" "$B_NPUB" >/dev/null || {
|
||||||
|
record_result "$id" fail "remove B failed"; return
|
||||||
|
}
|
||||||
|
# Let the Remove commit propagate to B's wnd.
|
||||||
|
local deadline=$(( $(date +%s) + 120 )) removed=0
|
||||||
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
|
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
||||||
|
| jq -e --arg p "$B_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
removed=1; break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[[ "$removed" -eq 1 ]] || warn "B still appears in members after remove — continuing"
|
||||||
|
|
||||||
|
# B republishes its KP so the next add can find a fresh one.
|
||||||
|
wn_b keys publish >/dev/null 2>&1 || true
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# A re-adds B.
|
||||||
|
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
|
||||||
|
record_result "$id" fail "re-add B failed"; return
|
||||||
|
}
|
||||||
|
wait_for_invite B 60 >/dev/null || {
|
||||||
|
record_result "$id" fail "B never received second invite"; return
|
||||||
|
}
|
||||||
|
wn_b groups accept "$mls_gid" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Regression probes: after re-add B must have a usable leaf again.
|
||||||
|
# (1) A→B: proves B can decrypt new group messages.
|
||||||
|
amy_json marmot message send "$gid" "post-readd hello" >/dev/null || {
|
||||||
|
record_result "$id" fail "A send after re-add failed"; return
|
||||||
|
}
|
||||||
|
wait_for_message B "$mls_gid" "post-readd hello" 120 || {
|
||||||
|
record_result "$id" fail "B didn't decrypt post-readd message (missing own_leaf?)"; return
|
||||||
|
}
|
||||||
|
# (2) B→A: proves B can still sign/commit — the exact breakage reported.
|
||||||
|
wn_b messages send "$mls_gid" "post-readd pong" >/dev/null 2>&1 || {
|
||||||
|
record_result "$id" fail "B send after re-add failed (likely no leaf)"; return
|
||||||
|
}
|
||||||
|
if amy_json marmot await message "$gid" --match "post-readd pong" --timeout 120 >/dev/null; then
|
||||||
|
record_result "$id" pass
|
||||||
|
else
|
||||||
|
record_result "$id" fail "A never saw B's post-readd pong (B couldn't type)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
test_11_leave_group() {
|
test_11_leave_group() {
|
||||||
banner "Test 11 — Leave group"
|
banner "Test 11 — Leave group"
|
||||||
local id="11 leave group"
|
local id="11 leave group"
|
||||||
|
|||||||
@@ -123,3 +123,4 @@ test_13_keypackage_rotation
|
|||||||
test_14_wn_removes_a
|
test_14_wn_removes_a
|
||||||
test_15_wn_member_leaves
|
test_15_wn_member_leaves
|
||||||
test_16_wn_keypackage_rotation
|
test_16_wn_keypackage_rotation
|
||||||
|
test_17_readd_after_remove
|
||||||
|
|||||||
Reference in New Issue
Block a user