feat: choose how a group is run before creating it

Add a third and final group-creation step, after the member picker, that
asks whether the new group should be convenient (the creator is its only
admin) or robust (every member is an admin and a change needs a
threshold of them to approve it).

The flow is now:
  ChatRoomCreationRoute          (name + description)
    -> SelectChatRoomMembersRoute    (pick people)
      -> SelectChatRoomTypeRoute     (how it is run, then build it)
        -> ChatRoomMessagingRoute

New: database/model/types/ChatRoomType.kt
* CONVENIENT / ROBUST, plus the two rules that go with them.
* approvalThreshold(adminCount) is a simple majority, so no half of the
  group can move without the other.
* MINIMUM_ROBUST_GROUP_SIZE = 3 and isRobustAvailable(memberCount).
  Below three a majority is not a meaningful check: at two admins every
  change needs both of them, and at one the creator is deciding alone,
  which is CONVENIENT under another name.

New: ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt
* Carries activeUserPublicKey, name, description and the picked
  memberPublicKeys. The governance choice feeds the epoch-0 group
  context, so nothing can be persisted until it has been made and every
  earlier answer has to ride along to this step.
* memberPublicKeys is a List<String>. Navigation 2.9.2 resolves that
  through NavType.StringListType (NavTypeConverter maps
  InternalType.STRING inside a collection onto it), so it needs no
  hand-rolled encoding.

New: ui/view/state/SelectChatRoomTypeUIState.kt
* Loading/Loaded/Error, with Loaded carrying the picked members so the
  screen can name them rather than echo hex keys.

New: ui/view/model/SelectChatRoomTypeViewModel.kt
* Owns the choice (selectedChatRoomType, convenient by default because
  it is the option that always works) and the group creation and invite
  round, both moved here wholesale from SelectChatRoomMembersViewModel.
* The choice is not cosmetic: it decides adminPubkeys on the epoch-0
  MarmotGroupData. CONVENIENT stamps just the creator; ROBUST stamps the
  creator plus every picked member, deduplicated (MIP-01 rejects
  duplicates). MarmotInboundManager.processGroupMembershipChanges
  already derives Participant.adminAt from exactly this list, so admin
  status propagates to every member's device without further work.
* MarmotGroupData.bootstrap() still stamps the base metadata -- the
  admin list is layered on with copy() -- so UI and CLI stay
  byte-identical on everything else.
* selectChatRoomType() refuses ROBUST when the group is too small, and
  freezes once the room exists: by then the choice is baked into the
  epoch-0 group context and re-picking would change nothing.
* robustUnavailableReason spells out the shortfall ("Go back and add 1
  more person"), pluralised here rather than in the composable.
* Known gap, left as a TODO next to the admin list: robust rooms get the
  admin set but not the t-of-n approval itself. That needs FROST signing
  over admin changes -- the same thing the existing "generate GID
  through frost" TODO is waiting on. Until then every admin of a robust
  room can still commit on their own.

New: ui/composable/SelectChatRoomTypeScreen.kt
* Two radio cards. Convenient explains that the creator acts alone and
  that nobody can carry the group on without them; robust quotes the
  real numbers -- "approved by 2 of 3 admins" -- computed from the
  actual selection instead of leaving t-of-n abstract.
* Below MINIMUM_ROBUST_GROUP_SIZE the robust card renders disabled
  (Card(enabled = false), disabled RadioButton, no click) and shows the
  reason in the error colour. It stays on screen rather than vanishing,
  so the option is discoverable and the fix -- go back, tick one more
  person -- is obvious.
* Carries the bottom bar the members step used to own: the
  create/progress/"Open chat" action and the partial-invite warning,
  which now name members via displayNameFor() since this step only
  receives keys.

SelectChatRoomMembersViewModel / SelectChatRoomMembersScreen
* Reduced to what their names say. Group creation, the invite round, the
  key package lookup, the wallet flow and ChatRepository all move to the
  type step; what stays is listing local profiles, ticking them, and
  handing the keys on through SelectChatRoomTypeRoute.
* The up-front key package sync stays here, which is the point of doing
  it early: it now has the whole type-selection step to land in before
  anybody is invited.
* The action becomes "Next with N" and the empty-store copy no longer
  offers to create the chat, because this step no longer can.

MantraNavHost
* Register SelectChatRoomTypeRoute. Opening the finished chat still pops
  back through ChatRoomCreationRoute inclusive, so backing out of a new
  chat lands where the user started rather than part-way through the
  three creation steps.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-08-29 16:44:29 +02:00
parent f21b13da07
commit bf94ecbe76
8 changed files with 846 additions and 269 deletions

View File

@@ -0,0 +1,40 @@
package press.mantra.compose.database.model.types
import kotlinx.serialization.Serializable
/**
* How a chat room is governed.
*
* The choice is stamped into the epoch-0 `MarmotGroupData.admin_pubkeys` list,
* which is what `MarmotInboundManager.processGroupMembershipChanges` reads to
* flag participants as admins on every side of the group.
*/
@Serializable
enum class ChatRoomType {
/** Only the member who created the room administers it. */
CONVENIENT,
/**
* Every member administers the room, and a change needs a threshold of them
* to approve it.
*/
ROBUST;
companion object {
/**
* Smallest group [ROBUST] is offered for. Below three, a majority is not a
* meaningful check: two admins means every change needs both of them, and
* one means the creator is deciding alone — which is [CONVENIENT] already.
*/
const val MINIMUM_ROBUST_GROUP_SIZE = 3
/**
* How many of [adminCount] admins have to approve a change — a simple
* majority, so no half of the group can move without the other.
*/
fun approvalThreshold(adminCount: Int): Int = adminCount / 2 + 1
/** Whether a group of [memberCount] people can be run as [ROBUST]. */
fun isRobustAvailable(memberCount: Int): Boolean = memberCount >= MINIMUM_ROBUST_GROUP_SIZE
}
}

View File

@@ -7,16 +7,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -37,7 +34,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.database.model.Profile
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
@@ -46,15 +42,13 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel
import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Second step of group creation: pick who is in the group, then create it.
* Second step of group creation: pick who is in the group.
*
* The list is whatever profiles are already in the local store — no directory
* lookup happens here.
* lookup happens here. Creating the group waits for the next step, where the
* user says how it should be run.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -63,9 +57,7 @@ fun SelectChatRoomMembersScreen(
name: String,
description: String?,
initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
) {
val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel(
@@ -74,9 +66,7 @@ fun SelectChatRoomMembersScreen(
name = name,
description = description,
initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState,
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
nostrRepository = nostrRepository
)
)
@@ -96,9 +86,7 @@ fun SelectChatRoomMembersScreen(
}
}
is SelectChatRoomMembersUIState.Loaded -> {
val isActionPending = selectChatRoomMembersViewModel.isActionPending.value
val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size
val membersNotAdded = selectChatRoomMembersViewModel.membersNotAdded
Scaffold(
topBar = {
@@ -117,27 +105,21 @@ fun SelectChatRoomMembersScreen(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomMembersViewModel.createChatRoom(
selectChatRoomMembersViewModel.selectChatRoomType(
onNavigateToRoute = onNavigateToRoute
)
}
) {
if (isActionPending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Check,
contentDescription = "Create chat"
)
}
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next"
)
Text(
text = when {
selectChatRoomMembersViewModel.createdChatRoomId.value != null -> "Open chat"
selectedCount > 0 -> "Create chat with $selectedCount"
else -> "Create chat"
text = if (selectedCount > 0) {
"Next with $selectedCount"
} else {
"Next"
}
)
}
@@ -159,22 +141,6 @@ fun SelectChatRoomMembersScreen(
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
if (membersNotAdded.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth().padding(10.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(15.dp),
text = "${name} was created, but ${membersNotAdded.joinToString { it.humanReadableNameOrPubkey() }} couldn't be added yet. Invite them again from the chat once they're on Torch.",
style = MaterialTheme.typography.bodyMedium
)
}
}
if (selectChatRoomMembersUIState.profiles.isEmpty()) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(20.dp),
@@ -198,7 +164,7 @@ fun SelectChatRoomMembersScreen(
)
Text(
text = "You can still create the chat now and invite people later.",
text = "You can still carry on and invite people later.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
@@ -317,9 +283,7 @@ private fun SelectChatRoomMembersScreenPreview() {
)
)
),
activeWalletStateFlow = MutableStateFlow(null),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {}
)
}

View File

@@ -0,0 +1,377 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel
import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Last step of group creation: convenient (one admin) or robust (everyone
* admins, changes need a threshold to approve), and then build the group.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SelectChatRoomTypeScreen(
activeUserPublicKey: HexKey,
name: String,
description: String?,
memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
) {
val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel(
factory = SelectChatRoomTypeViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
name = name,
description = description,
memberPublicKeys = memberPublicKeys,
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
)
)
when (val selectChatRoomTypeUIState = selectChatRoomTypeViewModel.selectChatRoomTypeUIState) {
is SelectChatRoomTypeUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = selectChatRoomTypeUIState.message,
textAlign = TextAlign.Center
)
}
}
is SelectChatRoomTypeUIState.Loaded -> {
val isActionPending = selectChatRoomTypeViewModel.isActionPending.value
val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value
val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded
val adminCount = selectChatRoomTypeViewModel.adminCount
val approvalThreshold = selectChatRoomTypeViewModel.approvalThreshold
val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "How should $name be run?",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
},
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
selectChatRoomTypeViewModel.createChatRoom(
onNavigateToRoute = onNavigateToRoute
)
}
) {
if (isActionPending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Check,
contentDescription = "Create chat"
)
}
Text(
text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) {
"Open chat"
} else {
"Create chat"
}
)
}
},
actions = {
Text(
modifier = Modifier.padding(start = 15.dp),
text = when (selectChatRoomTypeUIState.members.size) {
0 -> "Just you for now"
1 -> "You and 1 other"
else -> "You and ${selectChatRoomTypeUIState.members.size} others"
},
style = MaterialTheme.typography.labelLarge
)
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (membersNotAdded.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
modifier = Modifier.padding(15.dp),
text = "$name was created, but ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.",
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(
text = "This decides who can change the group later. You can't switch afterwards.",
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp)
)
ChatRoomTypeCard(
icon = Icons.Default.Bolt,
title = "Convenient",
summary = "You are the only admin.",
detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.",
footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.",
isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT)
}
)
ChatRoomTypeCard(
icon = Icons.Default.Groups,
title = "Robust",
summary = "Every member is an admin.",
detail = if (isRobustAvailable) {
"All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by $approvalThreshold of the $adminCount admins before it takes effect."
} else {
"Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a majority of the admins before it takes effect."
},
footnote = "No single admin can change the group alone, and the group outlives any one of you.",
isSelected = selectedChatRoomType == ChatRoomType.ROBUST,
isEnabled = isRobustAvailable,
disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason,
onClick = {
selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST)
}
)
}
}
}
SelectChatRoomTypeUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
20.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "How should $name be run?",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
if (initialSelectChatRoomTypeUIState == SelectChatRoomTypeUIState.Loading) {
selectChatRoomTypeViewModel.initiate()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatRoomTypeCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
summary: String,
detail: String,
footnote: String,
isSelected: Boolean,
onClick: () -> Unit,
isEnabled: Boolean = true,
disabledReason: String? = null,
) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
enabled = isEnabled,
colors = if (isSelected) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
} else {
CardDefaults.cardColors()
}
) {
Column(
modifier = Modifier.fillMaxWidth().padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = onClick,
enabled = isEnabled
)
Icon(
imageVector = icon,
contentDescription = null
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
}
Text(
text = summary,
style = MaterialTheme.typography.titleSmall
)
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = footnote,
style = MaterialTheme.typography.labelMedium
)
disabledReason?.let {
Text(
text = it,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Preview
@Composable
private fun SelectChatRoomTypeScreenPreview() {
TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
SelectChatRoomTypeScreen(
activeUserPublicKey = "",
name = "Group Discussions",
description = "See something... say something.",
memberPublicKeys = listOf("hex", "otherHex"),
initialSelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loaded(
members = listOf(
Profile(
publicKey = "hex",
displayName = "Man With A Plan",
about = "Making plans and getting things done...",
nostrEventId = "nostrEventId"
),
Profile(
publicKey = "otherHex",
displayName = "Woman Of Few Words",
about = "Says it once, says it well.",
nostrEventId = "otherNostrEventId"
)
)
),
activeWalletStateFlow = MutableStateFlow(null),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {}
)
}
}
}

View File

@@ -35,6 +35,7 @@ import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen
import press.mantra.compose.ui.composable.SearchResultScreen
import press.mantra.compose.ui.composable.SearchScreen
import press.mantra.compose.ui.composable.SelectChatRoomMembersScreen
import press.mantra.compose.ui.composable.SelectChatRoomTypeScreen
import press.mantra.compose.ui.composable.ShareProfileScreen
import press.mantra.compose.ui.composable.SignInToProfileScreen
import press.mantra.compose.ui.composable.SocialPreconditionScreen
@@ -63,6 +64,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute
import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute
import press.mantra.compose.ui.composable.navigation.routes.SignInRoute
import press.mantra.compose.ui.composable.navigation.routes.SocialPreconditionRoute
@@ -424,6 +426,22 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey,
name = route.name,
description = route.description,
nostrRepository = databaseNostrRepository,
onNavigateToRoute = { selectChatRoomTypeRoute ->
navController.navigate(
selectChatRoomTypeRoute
)
}
)
}
composable<SelectChatRoomTypeRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SelectChatRoomTypeRoute>()
SelectChatRoomTypeScreen(
activeUserPublicKey = route.activeUserPublicKey,
name = route.name,
description = route.description,
memberPublicKeys = route.memberPublicKeys,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
@@ -431,7 +449,7 @@ fun MantraNavHost(
navController.navigate(
chatRoomResultRoute
) {
// Drop both creation steps: backing out of the new chat should land
// Drop every creation step: backing out of the new chat should land
// on whatever the user was doing before, not back in the form.
popUpTo(
ChatRoomCreationRoute(

View File

@@ -0,0 +1,16 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* Final step of group creation: how the group should be run. Everything the
* earlier steps collected rides along, because the governance choice feeds the
* epoch-0 group context and so nothing can be persisted before it is made.
*/
@Serializable
data class SelectChatRoomTypeRoute(
val activeUserPublicKey: String,
val name: String,
val description: String? = null,
val memberPublicKeys: List<String> = emptyList()
): Route()

View File

@@ -1,6 +1,5 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -13,53 +12,31 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.SynchronizationFilter
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute
import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.phoenix.data.ActiveWallet
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
/**
* Picks the members of a brand new group chat and, once the user confirms,
* creates the MLS group and invites everyone that was ticked.
* Second step of group creation: who the group is for.
*
* Only profiles already in the local store are offered — this screen is a
* picker, not a directory search.
* picker, not a directory search. Nothing is persisted here; the picked keys
* ride on to [SelectChatRoomTypeViewModel], which mints the group.
*/
class SelectChatRoomMembersViewModel(
val activeUserPublicKey: HexKey,
val name: String,
val description: String?,
initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
): ViewModel() {
var selectChatRoomMembersUIState: SelectChatRoomMembersUIState by mutableStateOf(initialSelectChatRoomMembersUIState)
@@ -67,21 +44,9 @@ class SelectChatRoomMembersViewModel(
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/** Public keys the user has ticked, in the order they were ticked. */
val selectedPublicKeys = mutableStateListOf<HexKey>()
/**
* Set as soon as the room exists. Creating a group is not idempotent, so a second
* tap after a partially failed invite round has to reuse this room rather than
* mint another one.
*/
val createdChatRoomId: MutableState<String?> = mutableStateOf(null)
/** Members the group was created without, because no key package ever showed up. */
val membersNotAdded = mutableStateListOf<Profile>()
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val profiles = nostrRepository.searchableProfiles(
@@ -93,17 +58,13 @@ class SelectChatRoomMembersViewModel(
)
// Ask for everybody's key package up front: by the time the user has finished
// ticking names the relay round trip is usually done, so creating the group
// doesn't have to sit and wait for it.
// ticking names and chosen how the group is run, the relay round trip is
// usually done, so creating the group doesn't have to sit and wait for it.
scheduleKeyPackageEventSynchronization(profiles)
}
}
fun toggleMember(publicKey: HexKey) {
// Once the room exists the picker is spent — further ticks would silently do
// nothing, since creation (and with it the invite round) has already run.
if (isActionPending.value || createdChatRoomId.value != null) return
if (!selectedPublicKeys.remove(publicKey)) {
selectedPublicKeys.add(publicKey)
}
@@ -111,174 +72,19 @@ class SelectChatRoomMembersViewModel(
fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey)
fun createChatRoom(
fun selectChatRoomType(
onNavigateToRoute: (Route) -> Unit
) {
if (isActionPending.value) return
logger.d("selectChatRoomType with ${selectedPublicKeys.size} member(s)")
// The room survived a partial invite round — the user has read who was left out
// and just wants to get on with the chat.
createdChatRoomId.value?.let { chatRoomId ->
onNavigateToRoute.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = null
)
)
return
}
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
selectChatRoomMembersUIState = SelectChatRoomMembersUIState.Error(
"Couldn't read your keys. Please try again."
)
return
}
isActionPending.value = true
val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray()
)
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
// so later invitees receive a pre-populated group from the
// welcome and never have to chase an undecryptable bootstrap
// commit that predates their membership.
val metadata = MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = keyPair.pubKey.toHexKey(),
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
name = name,
description = description.orEmpty()
)
val extras = listOf(metadata.toExtension())
val signingKeyPair = Ed25519.generateKeyPair()
// TODO: Use nostrPrivateKey Ed25519.publicFromPrivate(privateKey).let { ed25519pubKey ->
// Ed25519KeyPair(privateKey, ed25519pubKey)
// }
val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, extras)
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getOrCreateChatRoom(
// Key the room on the Marmot `nostr_group_id` baked into the group's
// 0xF2EE extension, NOT on `MlsGroup`'s own randomly generated groupId —
// they are unrelated 32-byte values. GroupEvents are h-tagged with the
// room id, while every inbound path (and the invitee's own join) resolves
// rooms by nostrGroupId, so using the MLS id meant the two sides could
// never see each other's events.
chatRoomId = gid,
activeUserPublicKey = keyPair.pubKey.toHexKey(),
relayHint = null,
defaultSubject = name,
onNavigateToRoute.invoke(
SelectChatRoomTypeRoute(
activeUserPublicKey = activeUserPublicKey,
name = name,
description = description,
mlsGroupState = group.saveState().encodeTls().toHex()
memberPublicKeys = selectedPublicKeys.toList()
)
if (localChatRoom == null) {
withContext(Dispatchers.Main) {
isActionPending.value = false
onNavigateToRoute.invoke(
ImplementationPendingRoute("Something went wrong")
)
}
return@launch
}
createdChatRoomId.value = localChatRoom.chatRoom.id
val notAdded = inviteSelectedMembers(chatRoomId = localChatRoom.chatRoom.id)
withContext(Dispatchers.Main) {
isActionPending.value = false
if (notAdded.isEmpty()) {
onNavigateToRoute.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = keyPair.pubKey.toHexKey(),
chatRoomId = localChatRoom.chatRoom.id,
relayHint = null
)
)
} else {
// The group is real and usable without them; say who is missing instead
// of dropping the user into a chat that is quietly short a few people.
membersNotAdded.clear()
membersNotAdded.addAll(notAdded)
}
}
}
}
/**
* Adds every ticked profile to the freshly created group and returns the ones that
* could not be added.
*/
private suspend fun inviteSelectedMembers(chatRoomId: String): List<Profile> {
val selectedProfiles = selectedProfiles()
if (selectedProfiles.isEmpty()) return emptyList()
// Resolve the key packages concurrently so the whole batch costs one relay round
// trip rather than one per member.
val keyPackages = coroutineScope {
selectedProfiles.map { profile ->
async {
profile to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) {
chatRepository.observeMarmotKeyPackageForPublicKey(profile.publicKey)
.filterNotNull()
.first()
}
}
}.awaitAll()
}
val notAdded = mutableListOf<Profile>()
keyPackages.forEach { (profile, marmotKeyPackage) ->
if (marmotKeyPackage == null) {
logger.w("No key package for ${profile.publicKey}; leaving them out of $chatRoomId")
notAdded.add(profile)
return@forEach
}
// Re-read the room before each invite: `inviteMember` advances the MLS epoch and
// persists the new state, so reusing the snapshot taken before the previous
// invite would build this commit on top of state the group has already left.
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
logger.e("Chat room $chatRoomId disappeared mid-invite")
notAdded.add(profile)
return@forEach
}
runCatching {
chatRepository.inviteMember(
localChatRoom = localChatRoom,
peerPublicKey = profile.publicKey,
peerKeyPackage = marmotKeyPackage
)
}.onFailure { throwable ->
logger.e("Failed to invite ${profile.publicKey} to $chatRoomId", throwable)
notAdded.add(profile)
}
}
return notAdded
}
private fun selectedProfiles(): List<Profile> {
val profiles = (selectChatRoomMembersUIState as? SelectChatRoomMembersUIState.Loaded)?.profiles
?: return emptyList()
return profiles.filter { selectedPublicKeys.contains(it.publicKey) }
)
}
private suspend fun scheduleKeyPackageEventSynchronization(profiles: List<Profile>) {
@@ -311,17 +117,12 @@ class SelectChatRoomMembersViewModel(
companion object {
private const val TAG = "SelectChatRoomMembersViewModel"
/** Shared budget for pulling every selected member's key package off the relays. */
private val KEY_PACKAGE_LOOKUP_TIMEOUT = 20.seconds
fun factory(
activeUserPublicKey: HexKey,
name: String,
description: String?,
initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository
nostrRepository: NostrRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SelectChatRoomMembersViewModel(
@@ -329,9 +130,7 @@ class SelectChatRoomMembersViewModel(
name = name,
description = description,
initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState,
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
nostrRepository = nostrRepository
)
}
}

View File

@@ -0,0 +1,347 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.phoenix.data.ActiveWallet
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
/**
* Last step of group creation: how the group should be run, and then actually
* building it.
*
* The choice is not cosmetic — it decides who lands in the epoch-0
* `admin_pubkeys` list, so the group can only be minted once it has been made.
*/
class SelectChatRoomTypeViewModel(
val activeUserPublicKey: HexKey,
val name: String,
val description: String?,
val memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
): ViewModel() {
var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/** Convenient until the user says otherwise: it is the choice that always works. */
val selectedChatRoomType: MutableState<ChatRoomType> = mutableStateOf(ChatRoomType.CONVENIENT)
/**
* Set as soon as the room exists. Creating a group is not idempotent, so a second
* tap after a partially failed invite round has to reuse this room rather than
* mint another one.
*/
val createdChatRoomId: MutableState<String?> = mutableStateOf(null)
/** Members the group was created without, because no key package ever showed up. */
val membersNotAdded = mutableStateListOf<HexKey>()
/**
* Everyone who administers the group under [ChatRoomType.ROBUST]: the picked
* members plus the creator.
*/
val adminCount: Int = (memberPublicKeys + activeUserPublicKey).distinct().size
/** How many of [adminCount] admins a change needs, under [ChatRoomType.ROBUST]. */
val approvalThreshold: Int = ChatRoomType.approvalThreshold(adminCount)
/** Whether this group is big enough to be run as [ChatRoomType.ROBUST]. */
val isRobustAvailable: Boolean = ChatRoomType.isRobustAvailable(adminCount)
/** Why robust is off the table, or null when it is available. */
val robustUnavailableReason: String? = if (isRobustAvailable) {
null
} else {
val shortfall = ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE - adminCount
val missing = if (shortfall == 1) "1 more person" else "$shortfall more people"
"Needs at least ${ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE} people in the group. Go back and add $missing."
}
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val members = memberPublicKeys.mapNotNull { publicKey ->
nostrRepository.getProfileWithPublicKey(publicKey)
}
selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loaded(
members = members
)
}
}
fun selectChatRoomType(chatRoomType: ChatRoomType) {
// Once the room exists the choice is baked into its epoch-0 group context and
// re-picking here would change nothing.
if (isActionPending.value || createdChatRoomId.value != null) return
// Belt and braces alongside the disabled card: a group this small cannot carry
// a meaningful threshold, so it never becomes robust.
if (chatRoomType == ChatRoomType.ROBUST && !isRobustAvailable) return
selectedChatRoomType.value = chatRoomType
}
/** Names the member behind [publicKey], falling back to the key itself. */
fun displayNameFor(publicKey: HexKey): String {
val members = (selectChatRoomTypeUIState as? SelectChatRoomTypeUIState.Loaded)?.members
return members?.firstOrNull { it.publicKey == publicKey }?.humanReadableNameOrPubkey()
?: publicKey.take(SHORTENED_PUBLIC_KEY_LENGTH)
}
fun createChatRoom(
onNavigateToRoute: (Route) -> Unit
) {
if (isActionPending.value) return
// The room survived a partial invite round — the user has read who was left out
// and just wants to get on with the chat.
createdChatRoomId.value?.let { chatRoomId ->
onNavigateToRoute.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = null
)
)
return
}
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error(
"Couldn't read your keys. Please try again."
)
return
}
isActionPending.value = true
val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray()
)
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
// Convenient rooms keep the creator as the lone admin; robust rooms hand
// everyone the same authority. This is the whole behavioural difference
// between the two — the inbound path derives Participant.adminAt from
// exactly this list.
// TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST
// signing over admin changes. Today the list is set but every admin can
// still commit on their own.
val adminPubkeys = when (selectedChatRoomType.value) {
ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey())
ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct()
}
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
// so later invitees receive a pre-populated group from the
// welcome and never have to chase an undecryptable bootstrap
// commit that predates their membership.
val metadata = MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = keyPair.pubKey.toHexKey(),
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
name = name,
description = description.orEmpty()
).copy(
adminPubkeys = adminPubkeys
)
val extras = listOf(metadata.toExtension())
val signingKeyPair = Ed25519.generateKeyPair()
// TODO: Use nostrPrivateKey Ed25519.publicFromPrivate(privateKey).let { ed25519pubKey ->
// Ed25519KeyPair(privateKey, ed25519pubKey)
// }
val group = MlsGroup.create(keyPair.pubKey, signingKeyPair.privateKey, extras)
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getOrCreateChatRoom(
// Key the room on the Marmot `nostr_group_id` baked into the group's
// 0xF2EE extension, NOT on `MlsGroup`'s own randomly generated groupId —
// they are unrelated 32-byte values. GroupEvents are h-tagged with the
// room id, while every inbound path (and the invitee's own join) resolves
// rooms by nostrGroupId, so using the MLS id meant the two sides could
// never see each other's events.
chatRoomId = gid,
activeUserPublicKey = keyPair.pubKey.toHexKey(),
relayHint = null,
defaultSubject = name,
description = description,
mlsGroupState = group.saveState().encodeTls().toHex()
)
if (localChatRoom == null) {
withContext(Dispatchers.Main) {
isActionPending.value = false
onNavigateToRoute.invoke(
ImplementationPendingRoute("Something went wrong")
)
}
return@launch
}
createdChatRoomId.value = localChatRoom.chatRoom.id
val notAdded = inviteMembers(chatRoomId = localChatRoom.chatRoom.id)
withContext(Dispatchers.Main) {
isActionPending.value = false
if (notAdded.isEmpty()) {
onNavigateToRoute.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = keyPair.pubKey.toHexKey(),
chatRoomId = localChatRoom.chatRoom.id,
relayHint = null
)
)
} else {
// The group is real and usable without them; say who is missing instead
// of dropping the user into a chat that is quietly short a few people.
membersNotAdded.clear()
membersNotAdded.addAll(notAdded)
}
}
}
}
/**
* Adds every picked member to the freshly created group and returns the ones that
* could not be added.
*/
private suspend fun inviteMembers(chatRoomId: String): List<HexKey> {
if (memberPublicKeys.isEmpty()) return emptyList()
// Resolve the key packages concurrently so the whole batch costs one relay round
// trip rather than one per member.
val keyPackages = coroutineScope {
memberPublicKeys.map { publicKey ->
async {
publicKey to withTimeoutOrNull(KEY_PACKAGE_LOOKUP_TIMEOUT) {
chatRepository.observeMarmotKeyPackageForPublicKey(publicKey)
.filterNotNull()
.first()
}
}
}.awaitAll()
}
val notAdded = mutableListOf<HexKey>()
keyPackages.forEach { (publicKey, marmotKeyPackage) ->
if (marmotKeyPackage == null) {
logger.w("No key package for $publicKey; leaving them out of $chatRoomId")
notAdded.add(publicKey)
return@forEach
}
// Re-read the room before each invite: `inviteMember` advances the MLS epoch and
// persists the new state, so reusing the snapshot taken before the previous
// invite would build this commit on top of state the group has already left.
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
logger.e("Chat room $chatRoomId disappeared mid-invite")
notAdded.add(publicKey)
return@forEach
}
runCatching {
chatRepository.inviteMember(
localChatRoom = localChatRoom,
peerPublicKey = publicKey,
peerKeyPackage = marmotKeyPackage
)
}.onFailure { throwable ->
logger.e("Failed to invite $publicKey to $chatRoomId", throwable)
notAdded.add(publicKey)
}
}
return notAdded
}
companion object {
private const val TAG = "SelectChatRoomTypeViewModel"
/** Shared budget for pulling every picked member's key package off the relays. */
private val KEY_PACKAGE_LOOKUP_TIMEOUT = 20.seconds
/** Enough of a pubkey to tell two members apart when no profile is stored. */
private const val SHORTENED_PUBLIC_KEY_LENGTH = 12
fun factory(
activeUserPublicKey: HexKey,
name: String,
description: String?,
memberPublicKeys: List<HexKey>,
initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SelectChatRoomTypeViewModel(
activeUserPublicKey = activeUserPublicKey,
name = name,
description = description,
memberPublicKeys = memberPublicKeys,
initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState,
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
)
}
}
}
}

View File

@@ -0,0 +1,16 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.Profile
sealed interface SelectChatRoomTypeUIState {
data class Loaded(
/** The picked members, for naming them back to the user. */
val members: List<Profile>
): SelectChatRoomTypeUIState
data class Error(
val message: String
): SelectChatRoomTypeUIState
data object Loading: SelectChatRoomTypeUIState
}