diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index aabbb4236..d7d6d5699 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1895,6 +1895,38 @@ class Account( client.publish(outbound.signedEvent, groupRelays) } + /** + * Remove a member from a Marmot MLS group. + * Publishes the commit GroupEvent to group relays. + */ + suspend fun removeMarmotGroupMember( + nostrGroupId: HexKey, + targetLeafIndex: Int, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val outbound = manager.removeMember(nostrGroupId, targetLeafIndex) + client.publish(outbound.signedEvent, groupRelays) + } + + /** + * Update a Marmot MLS group's metadata (name, description, etc.). + * Publishes the commit GroupEvent to group relays. + */ + suspend fun updateMarmotGroupMetadata( + nostrGroupId: HexKey, + metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val outbound = manager.updateGroupMetadata(nostrGroupId, metadata) + client.publish(outbound.signedEvent, groupRelays) + } + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 94a5248af..8647f9c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -73,7 +73,8 @@ class MarmotGroupEventsEoseManager( metadata ?.relays ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.normalizeOrNull(it) + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) }?.toSet() val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays for (relay in relaysForGroup) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3a75e252c..623e5e6a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -73,9 +73,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa 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 @@ -293,6 +295,8 @@ fun BuildNavigation( composableFromBottom { CreateGroupScreen(accountViewModel, nav) } composableFromBottomArgs { AddMemberScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromBottomArgs { RemoveMemberScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromBottomArgs { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 58e59fa45..224ea88c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -279,6 +279,14 @@ sealed class Route { val nostrGroupId: String, ) : Route() + @Serializable data class MarmotGroupRemoveMember( + val nostrGroupId: String, + ) : Route() + + @Serializable data class MarmotGroupEditInfo( + val nostrGroupId: String, + ) : Route() + @Serializable data class NewGroupDM( val message: String? = null, val attachment: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 16b1c9fe3..f11b456f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1494,6 +1494,31 @@ class AccountViewModel( memberPubKey: String, ): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) + suspend fun removeMarmotGroupMember( + nostrGroupId: String, + targetLeafIndex: Int, + ) { + val relays = marmotGroupRelays(nostrGroupId) + account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) + } + + suspend fun updateMarmotGroupMetadata( + nostrGroupId: String, + name: String, + description: String, + ) { + val currentMetadata = + account.marmotManager?.groupMetadata(nostrGroupId) + ?: throw IllegalStateException("Group metadata not found") + val updatedMetadata = + currentMetadata.copy( + name = name, + description = description, + ) + val relays = marmotGroupRelays(nostrGroupId) + account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) + } + override fun onCleared() { Log.d("AccountViewModel", "onCleared") callController?.cleanup() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 8791601c1..c55d963f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -496,6 +496,10 @@ class GroupEventHandler( Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") } + is GroupEventResult.Duplicate -> { + Log.d("GroupEventHandler") { "Duplicate GroupEvent for group ${result.groupId}" } + } + is GroupEventResult.Error -> { Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt new file mode 100644 index 000000000..0f27c6e7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -0,0 +1,153 @@ +/* + * 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.layout.Column +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.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun EditGroupInfoScreen( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val chatroom = + remember(nostrGroupId) { + accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + } + val currentName by chatroom.displayName.collectAsStateWithLifecycle() + val currentDescription by chatroom.description.collectAsStateWithLifecycle() + + var name by remember(currentName) { mutableStateOf(currentName ?: "") } + var description by remember(currentDescription) { mutableStateOf(currentDescription ?: "") } + var isSaving by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "") + + Scaffold( + topBar = { + ActionTopBar( + postRes = R.string.save, + onCancel = { nav.popBack() }, + onPost = { + isSaving = true + scope.launch(Dispatchers.IO) { + try { + accountViewModel.updateMarmotGroupMetadata( + nostrGroupId = nostrGroupId, + name = name.trim(), + description = description.trim(), + ) + launch(Dispatchers.Main) { + Toast + .makeText(context, "Group info updated", Toast.LENGTH_SHORT) + .show() + } + nav.popBack() + } catch (e: Exception) { + isSaving = false + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to update: ${e.message}", + Toast.LENGTH_LONG, + ).show() + } + } + } + }, + isActive = { !isSaving && hasChanges && name.isNotBlank() }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Group name") }, + placeholder = { Text("Enter group name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isSaving, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + placeholder = { Text("Enter group description (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + enabled = !isSaving, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Changes will be committed to the group via MLS and propagated to all members.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 0d07128e5..a724f2f53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -33,7 +33,9 @@ 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.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 import androidx.compose.material3.HorizontalDivider @@ -108,6 +110,12 @@ fun MarmotGroupInfoScreen( }, title = { Text("Group Info") }, actions = { + IconButton(onClick = { nav.nav(Route.MarmotGroupEditInfo(nostrGroupId)) }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit Group Info", + ) + } IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) { Icon( imageVector = Icons.Default.GroupAdd, @@ -194,9 +202,28 @@ fun MarmotGroupInfoScreen( HorizontalDivider() } - // Leave group section + // 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/RemoveMemberScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/RemoveMemberScreen.kt new file mode 100644 index 000000000..f6b7b56da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/RemoveMemberScreen.kt @@ -0,0 +1,251 @@ +/* + * 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.LaunchedEffect +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 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, +) { + var members by remember { mutableStateOf(emptyList()) } + var memberToRemove by remember { mutableStateOf(null) } + var isRemoving by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val myPubkey = accountViewModel.account.signer.pubKey + val context = LocalContext.current + + LaunchedEffect(nostrGroupId) { + members = accountViewModel.marmotGroupMembers(nostrGroupId) + } + + 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) + members = accountViewModel.marmotGroupMembers(nostrGroupId) + 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") + } + }, + ) +}