Merge pull request #2173 from vitorpamplona/claude/marmot-group-management-Jpiux
Add member removal and group info editing for Marmot groups
This commit is contained in:
@@ -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<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 publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
|
||||
|
||||
@@ -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<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.MarmotGroupAddMember> { AddMemberScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.MarmotGroupRemoveMember> { RemoveMemberScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.MarmotGroupEditInfo> { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.PublicChatChannel> {
|
||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+1
-1
@@ -497,7 +497,7 @@ class GroupEventHandler(
|
||||
}
|
||||
|
||||
is GroupEventResult.Duplicate -> {
|
||||
Log.d("GroupEventHandler", "Duplicate event for group ${result.groupId}")
|
||||
Log.d("GroupEventHandler") { "Duplicate GroupEvent for group ${result.groupId}" }
|
||||
}
|
||||
|
||||
is GroupEventResult.Error -> {
|
||||
|
||||
+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.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
|
||||
|
||||
+12
-57
@@ -36,8 +36,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -45,8 +43,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,12 +50,10 @@ 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.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -71,8 +65,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -81,10 +73,6 @@ fun MarmotGroupListScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
var groupList by remember { mutableStateOf(listOf<Pair<HexKey, MarmotGroupChatroom>>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var isPublishing by remember { mutableStateOf(false) }
|
||||
var hasPublishedKeyPackage by remember { mutableStateOf(accountViewModel.hasPublishedKeyPackage()) }
|
||||
|
||||
// Load group list
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -98,6 +86,17 @@ fun MarmotGroupListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-publish KeyPackage if none exists yet
|
||||
LaunchedEffect(Unit) {
|
||||
if (!accountViewModel.hasPublishedKeyPackage()) {
|
||||
try {
|
||||
accountViewModel.publishMarmotKeyPackage()
|
||||
} catch (_: Exception) {
|
||||
// Silently retry on next screen visit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -110,47 +109,8 @@ fun MarmotGroupListScreen(
|
||||
}
|
||||
},
|
||||
title = { Text("Marmot Groups") },
|
||||
actions = {
|
||||
if (isPublishing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(12.dp).size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
isPublishing = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
accountViewModel.publishMarmotKeyPackage()
|
||||
hasPublishedKeyPackage = true
|
||||
snackbarHostState.showSnackbar("KeyPackage published successfully")
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to publish KeyPackage: ${e.message}")
|
||||
} finally {
|
||||
isPublishing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VpnKey,
|
||||
contentDescription =
|
||||
if (hasPublishedKeyPackage) "Republish KeyPackage" else "Publish KeyPackage",
|
||||
tint =
|
||||
if (hasPublishedKeyPackage) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Create Group")
|
||||
@@ -168,12 +128,7 @@ fun MarmotGroupListScreen(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (hasPublishedKeyPackage) {
|
||||
"Your KeyPackage is published. Waiting for group invitations."
|
||||
} else {
|
||||
"Tap the key icon above to publish your KeyPackage and receive invitations."
|
||||
},
|
||||
text = "Create a group or wait for an invitation.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
|
||||
+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