feat: add Remove Member and Edit Group Info screens for Marmot groups
Add route-based screens for group management instead of dialogs: - RemoveMemberScreen: lists removable members with confirmation dialog - EditGroupInfoScreen: edits group name and description via MLS metadata - Wire Account/ViewModel methods for removeMember and updateGroupMetadata - Add Edit and Remove Member actions to MarmotGroupInfoScreen toolbar - Fix pre-existing exhaustive when branch for GroupEventResult.Duplicate https://claude.ai/code/session_01LhfCp8DHqNVx6mSiYfpQny
This commit is contained in:
@@ -1895,6 +1895,38 @@ class Account(
|
|||||||
client.publish(outbound.signedEvent, groupRelays)
|
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<NormalizedRelayUrl>,
|
||||||
|
) {
|
||||||
|
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<NormalizedRelayUrl>,
|
||||||
|
) {
|
||||||
|
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 createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
|
||||||
|
|
||||||
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
|
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
|
||||||
|
|||||||
+2
-1
@@ -73,7 +73,8 @@ class MarmotGroupEventsEoseManager(
|
|||||||
metadata
|
metadata
|
||||||
?.relays
|
?.relays
|
||||||
?.mapNotNull {
|
?.mapNotNull {
|
||||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.normalizeOrNull(it)
|
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
.normalizeOrNull(it)
|
||||||
}?.toSet()
|
}?.toSet()
|
||||||
val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays
|
val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays
|
||||||
for (relay in relaysForGroup) {
|
for (relay in relaysForGroup) {
|
||||||
|
|||||||
@@ -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.bookmarkgroups.old.OldBookmarkListScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.AddMemberScreen
|
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.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
|
||||||
@@ -293,6 +295,8 @@ fun BuildNavigation(
|
|||||||
|
|
||||||
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
||||||
composableFromBottomArgs<Route.MarmotGroupAddMember> { AddMemberScreen(it.nostrGroupId, 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> {
|
composableFromEndArgs<Route.PublicChatChannel> {
|
||||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||||
|
|||||||
@@ -279,6 +279,14 @@ sealed class Route {
|
|||||||
val nostrGroupId: String,
|
val nostrGroupId: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data class MarmotGroupRemoveMember(
|
||||||
|
val nostrGroupId: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data class MarmotGroupEditInfo(
|
||||||
|
val nostrGroupId: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable data class NewGroupDM(
|
@Serializable data class NewGroupDM(
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val attachment: String? = null,
|
val attachment: String? = null,
|
||||||
|
|||||||
+25
@@ -1494,6 +1494,31 @@ class AccountViewModel(
|
|||||||
memberPubKey: String,
|
memberPubKey: String,
|
||||||
): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
|
): 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() {
|
override fun onCleared() {
|
||||||
Log.d("AccountViewModel", "onCleared")
|
Log.d("AccountViewModel", "onCleared")
|
||||||
callController?.cleanup()
|
callController?.cleanup()
|
||||||
|
|||||||
+4
@@ -496,6 +496,10 @@ class GroupEventHandler(
|
|||||||
Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}")
|
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 -> {
|
is GroupEventResult.Error -> {
|
||||||
Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" }
|
Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" }
|
||||||
}
|
}
|
||||||
|
|||||||
+153
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -33,7 +33,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
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.GroupAdd
|
import androidx.compose.material.icons.filled.GroupAdd
|
||||||
|
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
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -108,6 +110,12 @@ fun MarmotGroupInfoScreen(
|
|||||||
},
|
},
|
||||||
title = { Text("Group Info") },
|
title = { Text("Group Info") },
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = { nav.nav(Route.MarmotGroupEditInfo(nostrGroupId)) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit Group Info",
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
IconButton(onClick = { nav.nav(Route.MarmotGroupAddMember(nostrGroupId)) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.GroupAdd,
|
imageVector = Icons.Default.GroupAdd,
|
||||||
@@ -194,9 +202,28 @@ fun MarmotGroupInfoScreen(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave group section
|
// Group actions section
|
||||||
item {
|
item {
|
||||||
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
+251
@@ -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<GroupMemberInfo>()) }
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user