Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs
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.PostBookmarkListManagementScreen
|
||||
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.EditGroupInfoScreen
|
||||
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.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.ChatroomScreen
|
||||
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) }
|
||||
|
||||
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) }
|
||||
|
||||
composableFromEndArgs<Route.PublicChatChannel> {
|
||||
|
||||
@@ -370,14 +370,6 @@ sealed class 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(
|
||||
val nostrGroupId: String,
|
||||
) : 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 = {
|
||||
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
||||
IconButton(onClick = { nav.nav(Route.MarmotGroupInfo(nostrGroupId)) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GroupAdd,
|
||||
contentDescription = "Add Member",
|
||||
|
||||
+269
-118
@@ -32,8 +32,10 @@ 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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.ExitToApp
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material.icons.filled.PersonRemove
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -51,11 +52,13 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.note.RenderRelayIcon
|
||||
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.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.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
@@ -108,10 +114,24 @@ fun MarmotGroupInfoScreen(
|
||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||
var showLeaveDialog 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 myPubkey = accountViewModel.account.signer.pubKey
|
||||
val context = LocalContext.current
|
||||
|
||||
val userSuggestions =
|
||||
remember {
|
||||
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { userSuggestions.reset() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -131,10 +151,14 @@ fun MarmotGroupInfoScreen(
|
||||
contentDescription = "Edit Group Info",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
||||
IconButton(
|
||||
onClick = { showLeaveDialog = true },
|
||||
enabled = !isLeaving,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GroupAdd,
|
||||
contentDescription = "Add Member",
|
||||
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = "Leave Group",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -155,46 +179,90 @@ fun MarmotGroupInfoScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
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",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (groupRelays.isNotEmpty()) {
|
||||
GroupRelayList(
|
||||
relayUrls = groupRelays,
|
||||
relayActivity = relayActivity,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
|
||||
if (epoch != null) {
|
||||
Text(
|
||||
text = "Epoch: $epoch",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.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 = groupDescription!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
// 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
|
||||
item {
|
||||
Text(
|
||||
@@ -205,61 +273,21 @@ fun MarmotGroupInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Member list
|
||||
// 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 = member.pubkey == myPubkey,
|
||||
isAdmin = member.pubkey in adminPubkeys,
|
||||
isMe = isMe,
|
||||
isAdmin = isAdmin,
|
||||
canRemove = !isMe,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onRemoveClick = { memberToRemove = member },
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +301,7 @@ fun MarmotGroupInfoScreen(
|
||||
try {
|
||||
accountViewModel.leaveMarmotGroup(nostrGroupId)
|
||||
accountViewModel.account.marmotGroupList.removeGroup(nostrGroupId)
|
||||
nav.nav(Route.MarmotGroupList)
|
||||
nav.nav(Route.Message)
|
||||
} catch (e: Exception) {
|
||||
isLeaving = false
|
||||
launch(Dispatchers.Main) {
|
||||
@@ -290,6 +318,96 @@ fun MarmotGroupInfoScreen(
|
||||
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
|
||||
@@ -297,15 +415,17 @@ fun MemberRow(
|
||||
member: GroupMemberInfo,
|
||||
isMe: Boolean,
|
||||
isAdmin: Boolean,
|
||||
canRemove: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onRemoveClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
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. */
|
||||
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun GroupRelayList(
|
||||
fun GroupRelayStrip(
|
||||
relayUrls: List<String>,
|
||||
relayActivity: Map<NormalizedRelayUrl, Long>,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -378,29 +536,22 @@ fun GroupRelayList(
|
||||
|
||||
if (normalized.isEmpty()) return
|
||||
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(
|
||||
text = "Relays",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000L
|
||||
normalized.forEach { (raw, relay) ->
|
||||
val lastSeen = relayActivity[relay]
|
||||
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
|
||||
GroupRelayTile(
|
||||
relay = relay,
|
||||
fallbackUrl = raw,
|
||||
isActive = isActive,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000L
|
||||
normalized.forEach { (raw, relay) ->
|
||||
val lastSeen = relayActivity[relay]
|
||||
val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS
|
||||
GroupRelayTile(
|
||||
relay = relay,
|
||||
fallbackUrl = raw,
|
||||
isActive = isActive,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,7 +572,7 @@ fun GroupRelayTile(
|
||||
val clickableModifier =
|
||||
remember(relay) {
|
||||
Modifier
|
||||
.size(55.dp)
|
||||
.size(35.dp)
|
||||
.combinedClickable(
|
||||
indication = ripple24dp,
|
||||
interactionSource = MutableInteractionSource(),
|
||||
@@ -444,7 +595,7 @@ fun GroupRelayTile(
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
pingInMs = 0,
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
iconModifier = LargeRelayIconModifier,
|
||||
iconModifier = MediumRelayIconModifier,
|
||||
)
|
||||
|
||||
val dotColor =
|
||||
@@ -458,15 +609,15 @@ fun GroupRelayTile(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(12.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(2.dp),
|
||||
.padding(1.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+56
@@ -27,6 +27,7 @@ import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* End-to-end lifecycle tests for MlsGroup covering the full protocol flow:
|
||||
@@ -298,6 +299,61 @@ class MlsGroupLifecycleTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: removing a member and re-adding them must produce a fully-
|
||||
* functional group instance on the rejoiner's side. In the originally
|
||||
* reported bug, the re-added user came back with no usable own_leaf —
|
||||
* the UI showed the group but the user could neither decrypt new
|
||||
* messages nor send any. This exercises the full loop with a fresh
|
||||
* KeyPackage on re-add and asserts both directions still work.
|
||||
*/
|
||||
@Test
|
||||
fun testReaddAfterRemove_RejoinerCanEncryptAndDecrypt() {
|
||||
val alice = MlsGroup.create("alice".encodeToByteArray())
|
||||
|
||||
// First add: Bob joins.
|
||||
val firstBundle = createStandaloneKeyPackage("bob")
|
||||
val firstAdd = alice.addMember(firstBundle.keyPackage.toTlsBytes())
|
||||
val bob1 = MlsGroup.processWelcome(firstAdd.welcomeBytes!!, firstBundle)
|
||||
|
||||
// Baseline round-trip works before the remove.
|
||||
val hello = "hello before remove".encodeToByteArray()
|
||||
assertContentEquals(hello, bob1.decrypt(alice.encrypt(hello)).content)
|
||||
val pong = "pong before remove".encodeToByteArray()
|
||||
assertContentEquals(pong, alice.decrypt(bob1.encrypt(pong)).content)
|
||||
|
||||
// Alice removes Bob. bob1 is now stale (left-behind, no commit to apply).
|
||||
alice.removeMember(bob1.leafIndex)
|
||||
assertEquals(1, alice.memberCount)
|
||||
|
||||
// Re-add: Bob publishes a fresh KeyPackage, Alice adds it, Bob processes
|
||||
// the new Welcome from scratch. This is the exact flow driven by the
|
||||
// info screen's inline "Add member" after an earlier removal.
|
||||
val secondBundle = createStandaloneKeyPackage("bob")
|
||||
val secondAdd = alice.addMember(secondBundle.keyPackage.toTlsBytes())
|
||||
val bob2 = MlsGroup.processWelcome(secondAdd.welcomeBytes!!, secondBundle)
|
||||
|
||||
assertEquals(alice.epoch, bob2.epoch, "Rejoiner must be at Alice's epoch")
|
||||
assertEquals(2, alice.memberCount)
|
||||
assertEquals(2, bob2.memberCount)
|
||||
|
||||
// Own-leaf sanity: the rejoiner must actually hold a leaf in the tree.
|
||||
assertTrue(bob2.leafIndex >= 0, "Rejoiner must have a valid leafIndex after re-add")
|
||||
|
||||
// The bug surfaces here — if bob2 has no usable leaf / keying material,
|
||||
// one of these two round-trips fails.
|
||||
val afterHello = "hello after re-add".encodeToByteArray()
|
||||
val decrypted = bob2.decrypt(alice.encrypt(afterHello))
|
||||
assertContentEquals(afterHello, decrypted.content, "Rejoiner could not decrypt")
|
||||
assertEquals(0, decrypted.senderLeafIndex, "Sender should still be Alice at leaf 0")
|
||||
|
||||
val afterPong = "pong after re-add".encodeToByteArray()
|
||||
val bobEncrypted = bob2.encrypt(afterPong)
|
||||
val aliceSees = alice.decrypt(bobEncrypted)
|
||||
assertContentEquals(afterPong, aliceSees.content, "Rejoiner could not send (no usable leaf)")
|
||||
assertEquals(bob2.leafIndex, aliceSees.senderLeafIndex)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Signing key rotation (Update proposal)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user