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,
)
// 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") {
"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"}"
@@ -2270,6 +2277,8 @@ class Account(
if (!isWriteable()) return
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -85,19 +84,13 @@ fun MarmotGroupInfoScreen(
val groupDescription by chatroom.description.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.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 isLeaving by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val myPubkey = accountViewModel.account.signer.pubKey
val context = LocalContext.current
LifecycleResumeEffect(nostrGroupId) {
members = accountViewModel.marmotGroupMembers(nostrGroupId)
chatroom.memberCount.value = members.size
onPauseOrDispose {}
}
Scaffold(
topBar = {
TopAppBar(
@@ -44,7 +44,6 @@ 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
@@ -55,6 +54,7 @@ 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
@@ -71,17 +71,17 @@ fun RemoveMemberScreen(
accountViewModel: AccountViewModel,
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 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(
@@ -155,7 +155,6 @@ fun RemoveMemberScreen(
scope.launch(Dispatchers.IO) {
try {
accountViewModel.removeMarmotGroupMember(nostrGroupId, member.leafIndex)
members = accountViewModel.marmotGroupMembers(nostrGroupId)
isRemoving = false
launch(Dispatchers.Main) {
Toast
@@ -437,7 +437,9 @@ class MarmotManager(
chatroom.adminPubkeys.value = metadata.adminPubkeys
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
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.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
@@ -46,6 +47,7 @@ class MarmotGroupChatroom(
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0)
var members = MutableStateFlow<List<GroupMemberInfo>>(emptyList())
var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0)