refactor: make Marmot group members list reactive

Expose the member list as MutableStateFlow<List<GroupMemberInfo>> on
MarmotGroupChatroom, populated by MarmotManager.syncMetadataTo alongside
the existing memberCount. MarmotGroupInfoScreen and RemoveMemberScreen
now observe it via collectAsStateWithLifecycle, so both remote commits
(already routed through syncMetadataTo in DecryptAndIndexProcessor) and
local mutations refresh the UI without manual re-queries.

Account.addMarmotGroupMember and removeMarmotGroupMember now call
syncMetadataTo right after the MLS state is mutated, mirroring the
pattern already used by updateMarmotGroupMetadata, so the local change
is visible immediately without waiting for the relay round-trip.
This commit is contained in:
Claude
2026-04-20 16:27:31 +00:00
parent 7e45042c27
commit b6e31b21c8
5 changed files with 21 additions and 16 deletions
@@ -2070,6 +2070,13 @@ class Account(
relays = groupRelays, relays = groupRelays,
) )
// The MLS commit has already been applied to the local group state —
// surface the new member list in the chatroom now so observers (e.g.
// MarmotGroupInfoScreen) update without waiting for our own commit to
// loop back through the relay.
val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" + "addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}" "welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
@@ -2270,6 +2277,8 @@ class Account(
if (!isWriteable()) return if (!isWriteable()) return
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex) val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
client.publish(outbound.signedEvent, groupRelays) client.publish(outbound.signedEvent, groupRelays)
} }
@@ -58,7 +58,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -85,19 +84,13 @@ fun MarmotGroupInfoScreen(
val groupDescription by chatroom.description.collectAsStateWithLifecycle() val groupDescription by chatroom.description.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
val groupRelays by chatroom.relays.collectAsStateWithLifecycle() val groupRelays by chatroom.relays.collectAsStateWithLifecycle()
var members by remember { mutableStateOf(emptyList<GroupMemberInfo>()) } 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) }
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
LifecycleResumeEffect(nostrGroupId) {
members = accountViewModel.marmotGroupMembers(nostrGroupId)
chatroom.memberCount.value = members.size
onPauseOrDispose {}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -44,7 +44,6 @@ 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.LaunchedEffect
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
@@ -55,6 +54,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -71,17 +71,17 @@ fun RemoveMemberScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
var members by remember { mutableStateOf(emptyList<GroupMemberInfo>()) } val chatroom =
remember(nostrGroupId) {
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
}
val members by chatroom.members.collectAsStateWithLifecycle()
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) } var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
var isRemoving by remember { mutableStateOf(false) } var isRemoving 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
LaunchedEffect(nostrGroupId) {
members = accountViewModel.marmotGroupMembers(nostrGroupId)
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -155,7 +155,6 @@ fun RemoveMemberScreen(
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex) accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex)
members = accountViewModel.marmotGroupMembers(nostrGroupId)
isRemoving = false isRemoving = false
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
Toast Toast
@@ -437,7 +437,9 @@ class MarmotManager(
chatroom.adminPubkeys.value = metadata.adminPubkeys chatroom.adminPubkeys.value = metadata.adminPubkeys
chatroom.relays.value = metadata.relays chatroom.relays.value = metadata.relays
} }
chatroom.memberCount.value = memberCount(nostrGroupId) val members = memberPubkeys(nostrGroupId)
chatroom.members.value = members
chatroom.memberCount.value = members.size
} }
} }
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.model.marmotGroups package com.vitorpamplona.amethyst.commons.model.marmotGroups
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrder import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrder
import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
@@ -46,6 +47,7 @@ class MarmotGroupChatroom(
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList()) var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
var relays = MutableStateFlow<List<String>>(emptyList()) var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0) var memberCount = MutableStateFlow(0)
var members = MutableStateFlow<List<GroupMemberInfo>>(emptyList())
var newestMessage: Note? = null var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0) val unreadCount = MutableStateFlow(0)