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:
Claude
2026-04-22 19:28:25 +00:00
parent 2d6f626ee3
commit 65c56d0035
8 changed files with 359 additions and 686 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.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()
@@ -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 = { 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",
@@ -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),
) )
@@ -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