Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs

This commit is contained in:
Claude
2026-04-22 20:20:13 +00:00
7 changed files with 326 additions and 687 deletions
@@ -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()
@@ -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,
)
}
@@ -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",
@@ -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),
)
@@ -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")
}
},
)
}
@@ -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)
// -----------------------------------------------------------------------