feat: pick group members before creating a chat room
Group creation was a single screen: name + description, then "Create
chat" minted the MLS group and dropped the user straight into an empty
room, with no way to bring anyone along except the existing
one-at-a-time invite path (chat room detail -> search member ->
confirm). Add a second step that lists the profiles already in the
local store and lets the user tick everyone the group is for, so a
group is created together with its members.
The flow is now:
ChatRoomCreationRoute (name + description)
-> SelectChatRoomMembersRoute (pick people, create, invite)
-> ChatRoomMessagingRoute
New: ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt
* Carries activeUserPublicKey plus the name/description gathered by step
one, so nothing is persisted until the user confirms who is in.
* `description` is nullable with a default, and ChatRoomCreationViewModel
maps blank text onto null: an empty string is not something navigation
round-trips reliably as a path argument.
New: ui/view/state/SelectChatRoomMembersUIState.kt
* The Loading/Loaded/Error triple the other chat screens use. Loaded
carries the pickable profiles.
New: ui/view/model/SelectChatRoomMembersViewModel.kt
* initiate() lists nostrRepository.searchableProfiles() excluding the
active user -- purely local rows, no directory lookup. It also queues a
negentropy sync for every listed profile's KeyPackageEvent up front, so
the packages needed to actually add anybody have usually landed by the
time the user has finished ticking names.
* createChatRoom() moves here from ChatRoomCreationViewModel, unchanged
in substance: bootstrap MarmotGroupData into the epoch-0 GroupContext,
MlsGroup.create, then getOrCreateChatRoom keyed on the Marmot
nostr_group_id (not MlsGroup's own groupId). Minting the group here
rather than in step one is the point of the split -- backing out of the
picker no longer strands a member-less room in the chat list.
* inviteSelectedMembers() resolves the selected members' key packages
concurrently under one shared 20s budget (a single relay round trip for
the batch instead of one timeout per member) by observing
observeMarmotKeyPackageForPublicKey, then invites sequentially. The
room is re-read from the repository before every invite: inviteMember
advances the MLS epoch and persists the new state, so reusing the
snapshot taken before the previous invite would build the next commit
on top of state the group has already left.
* A member whose key package never shows up does not sink the group. It
is created without them and the screen names who was left out;
createdChatRoomId then turns the action into "Open chat" against the
room that already exists rather than minting a second one, and
toggleMember is frozen once the room exists so further ticks cannot
look like they will still be honoured.
New: ui/composable/SelectChatRoomMembersScreen.kt
* Checkbox list over Loaded.profiles, reusing the row shape of
SearchMemberToAddToChatRoomScreen (ProfileAvatar + name + about); both
the row and the checkbox toggle selection.
* BottomAppBar carries the running selection count and an
ExtendedFloatingActionButton labelled "Create chat with N", which
swaps to a progress indicator while the group is being built.
* An empty local store gets an explanatory state that still allows
creating the chat and inviting people later.
ChatRoomCreationViewModel
* Reduced to the details form. Group creation, the wallet keypair and
both repositories move to the picker, so factory() now takes no
arguments at all.
* Gains validateInput() (mirroring CreateProfileViewModel) so an unnamed
chat cannot advance, and selectMembers() to hand the collected
name/description to the next route.
ChatRoomCreationScreen
* Takes activeUserPublicKey from the route instead of the wallet flow
and the two repositories; the button becomes "Choose who to chat
with".
* Fix the "groupd" typo in the name placeholder.
MantraNavHost
* Register SelectChatRoomMembersRoute. Opening the finished chat pops
back through ChatRoomCreationRoute inclusive, so backing out of a
brand new chat lands where the user started rather than in the
half-filled creation form.
Known limits: the picker inherits ProfileDao's default LIMIT 21, so only
the first 21 local profiles are offered (the same cap the existing
add-member search already lives with), and a selected profile can only
join if their KeyPackageEvent is reachable -- contacts who have never
published one always land in the "couldn't be added" list.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,27 +25,17 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.view.model.ChatRoomCreationViewModel
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Composable
|
||||
fun ChatRoomCreationScreen(
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
onNavigateToChatRoom: (Route) -> Unit
|
||||
activeUserPublicKey: HexKey,
|
||||
onNavigateToRoute: (Route) -> Unit
|
||||
) {
|
||||
val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel (
|
||||
factory = ChatRoomCreationViewModel.factory(
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
)
|
||||
factory = ChatRoomCreationViewModel.factory()
|
||||
)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
@@ -84,7 +74,7 @@ fun ChatRoomCreationScreen(
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Enter the name you want to use for your groupd",
|
||||
text = "Enter the name you want to use for your group",
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
@@ -150,13 +140,14 @@ fun ChatRoomCreationScreen(
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
chatRoomCreationViewModel.createChatRoom(
|
||||
onNavigateToChatRoom = onNavigateToChatRoom
|
||||
chatRoomCreationViewModel.selectMembers(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
onNavigateToRoute = onNavigateToRoute
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Create chat"
|
||||
"Choose who to chat with"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -172,10 +163,8 @@ private fun LoadingScreenPreview() {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
ChatRoomCreationScreen(
|
||||
activeWalletStateFlow = MutableStateFlow(null),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
onNavigateToChatRoom = {}
|
||||
activeUserPublicKey = "",
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
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
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.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.composable.widgets.profile.ProfileAvatar
|
||||
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.
|
||||
*
|
||||
* The list is whatever profiles are already in the local store — no directory
|
||||
* lookup happens here.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectChatRoomMembersScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
name: String,
|
||||
description: String?,
|
||||
initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel(
|
||||
factory = SelectChatRoomMembersViewModel.factory(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
name = name,
|
||||
description = description,
|
||||
initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
)
|
||||
)
|
||||
|
||||
when (val selectChatRoomMembersUIState = selectChatRoomMembersViewModel.selectChatRoomMembersUIState) {
|
||||
is SelectChatRoomMembersUIState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier.height(50.dp)
|
||||
)
|
||||
Text(
|
||||
text = selectChatRoomMembersUIState.message,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
is SelectChatRoomMembersUIState.Loaded -> {
|
||||
val isActionPending = selectChatRoomMembersViewModel.isActionPending.value
|
||||
val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size
|
||||
val membersNotAdded = selectChatRoomMembersViewModel.membersNotAdded
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Add people to $name",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
selectChatRoomMembersViewModel.createChatRoom(
|
||||
onNavigateToRoute = onNavigateToRoute
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (isActionPending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Create chat"
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
selectChatRoomMembersViewModel.createdChatRoomId.value != null -> "Open chat"
|
||||
selectedCount > 0 -> "Create chat with $selectedCount"
|
||||
else -> "Create chat"
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 15.dp),
|
||||
text = if (selectedCount > 0) {
|
||||
"$selectedCount selected"
|
||||
} else {
|
||||
"No one selected yet"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
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),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "No one to add yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Search for people and chat with them first — everyone you know locally shows up here.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "You can still create the chat now and invite people later.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(2f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(
|
||||
items = selectChatRoomMembersUIState.profiles,
|
||||
key = { it.publicKey }
|
||||
) { profile ->
|
||||
val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey)
|
||||
|
||||
Card(
|
||||
onClick = {
|
||||
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
|
||||
}
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ProfileAvatar(
|
||||
profile = profile
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
profile.humanReadableNameOrPubkey()
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
profile.about?.let {
|
||||
Text(
|
||||
text = it,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = {
|
||||
selectChatRoomMembersViewModel.toggleMember(profile.publicKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectChatRoomMembersUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(
|
||||
20.dp
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Add people to $name",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
LoadingDataIndicator(
|
||||
fillScreen = false
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialSelectChatRoomMembersUIState == SelectChatRoomMembersUIState.Loading) {
|
||||
selectChatRoomMembersViewModel.initiate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SelectChatRoomMembersScreenPreview() {
|
||||
TorchTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
SelectChatRoomMembersScreen(
|
||||
activeUserPublicKey = "",
|
||||
name = "Group Discussions",
|
||||
description = "See something... say something.",
|
||||
initialSelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loaded(
|
||||
profiles = listOf(
|
||||
Profile(
|
||||
publicKey = "hex",
|
||||
displayName = "Man With A Plan",
|
||||
about = "Making plans and getting things done...",
|
||||
nostrEventId = "nostrEventId"
|
||||
)
|
||||
)
|
||||
),
|
||||
activeWalletStateFlow = MutableStateFlow(null),
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import press.mantra.compose.ui.composable.NostrEventDetailScreen
|
||||
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.ShareProfileScreen
|
||||
import press.mantra.compose.ui.composable.SignInToProfileScreen
|
||||
import press.mantra.compose.ui.composable.SocialPreconditionScreen
|
||||
@@ -61,6 +62,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
|
||||
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.ShareProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SignInRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SocialPreconditionRoute
|
||||
@@ -407,14 +409,37 @@ fun MantraNavHost(
|
||||
val route = backStackEntry.toRoute<ChatRoomCreationRoute>()
|
||||
|
||||
ChatRoomCreationScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
onNavigateToRoute = { selectMembersRoute ->
|
||||
navController.navigate(
|
||||
selectMembersRoute
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<SelectChatRoomMembersRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<SelectChatRoomMembersRoute>()
|
||||
|
||||
SelectChatRoomMembersScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
onNavigateToChatRoom = { chatRoomResultRoute ->
|
||||
onNavigateToRoute = { chatRoomResultRoute ->
|
||||
navController.navigate(
|
||||
chatRoomResultRoute
|
||||
) {
|
||||
popUpTo(route)
|
||||
// Drop both creation steps: backing out of the new chat should land
|
||||
// on whatever the user was doing before, not back in the form.
|
||||
popUpTo(
|
||||
ChatRoomCreationRoute(
|
||||
activeUserPublicKey = route.activeUserPublicKey
|
||||
)
|
||||
) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Second step of group creation: the name/description gathered by
|
||||
* [ChatRoomCreationRoute] travel with the route so the group is only ever
|
||||
* created once the user has picked who is in it.
|
||||
*/
|
||||
@Serializable
|
||||
data class SelectChatRoomMembersRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val name: String,
|
||||
val description: String? = null
|
||||
): Route()
|
||||
@@ -1,138 +1,65 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.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.SelectChatRoomMembersRoute
|
||||
import press.mantra.compose.ui.view.state.form.ChatRoomCreationFormState
|
||||
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.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.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* First step of group creation: collect what the group is called and what it is for.
|
||||
* The group itself is only minted once members have been picked, in
|
||||
* [SelectChatRoomMembersViewModel].
|
||||
*/
|
||||
class ChatRoomCreationViewModel(
|
||||
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val chatRoomCreationFormState: ChatRoomCreationFormState = ChatRoomCreationFormState(),
|
||||
): ViewModel() {
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
|
||||
fun createChatRoom(
|
||||
onNavigateToChatRoom: (Route) -> Unit
|
||||
) {
|
||||
// TODO: Load keypair...
|
||||
activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
|
||||
val keyPair = KeyPair(
|
||||
privKey = nostrPrivateKey.value.toByteArray()
|
||||
)
|
||||
|
||||
val name = chatRoomCreationFormState.nameField.textFieldState.text
|
||||
val description = chatRoomCreationFormState.descriptionField.textFieldState.text
|
||||
|
||||
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.toString(),
|
||||
description = description.toString()
|
||||
)
|
||||
|
||||
val extras = listOf(metadata.toExtension())
|
||||
|
||||
nostrPrivateKey.value.toByteArray().let { privateKey ->
|
||||
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.toString(),
|
||||
description = description.toString(),
|
||||
mlsGroupState = group.saveState().encodeTls().toHex()
|
||||
)
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
if (localChatRoom != null) {
|
||||
// TODO: Broadcast the thing...
|
||||
|
||||
// TODO: Navigate to invitation screen...
|
||||
onNavigateToChatRoom.invoke(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = keyPair.pubKey.toHexKey(),
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
relayHint = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
onNavigateToChatRoom.invoke(
|
||||
ImplementationPendingRoute("Something went wrong")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
fun validateInput(): Boolean {
|
||||
if (chatRoomCreationFormState.nameField.textFieldState.text.isBlank()) {
|
||||
chatRoomCreationFormState.nameField.errorMessage.value = "Please input a name for the chat"
|
||||
return false
|
||||
} else {
|
||||
chatRoomCreationFormState.nameField.errorMessage.value = null
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun selectMembers(
|
||||
activeUserPublicKey: HexKey,
|
||||
onNavigateToRoute: (Route) -> Unit
|
||||
) {
|
||||
if (!validateInput()) return
|
||||
|
||||
val description = chatRoomCreationFormState.descriptionField.textFieldState.text.toString()
|
||||
|
||||
logger.d("selectMembers for new chat room")
|
||||
|
||||
onNavigateToRoute.invoke(
|
||||
SelectChatRoomMembersRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
name = chatRoomCreationFormState.nameField.textFieldState.text.toString(),
|
||||
// Keep empty text out of the route: a blank path argument is not something
|
||||
// navigation round-trips reliably.
|
||||
description = description.ifBlank { null }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatRoomCreationViewModel"
|
||||
|
||||
fun factory(
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
fun factory(): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
ChatRoomCreationViewModel(
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
)
|
||||
ChatRoomCreationViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
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.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.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.
|
||||
*
|
||||
* Only profiles already in the local store are offered — this screen is a
|
||||
* picker, not a directory search.
|
||||
*/
|
||||
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)
|
||||
private set
|
||||
|
||||
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(
|
||||
excludingPublicKeys = arrayOf(activeUserPublicKey)
|
||||
)
|
||||
|
||||
selectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loaded(
|
||||
profiles = profiles
|
||||
)
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey)
|
||||
|
||||
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) {
|
||||
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,
|
||||
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 = 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>) {
|
||||
if (profiles.isEmpty()) return
|
||||
|
||||
// TODO: Sync from aggregated relays of profile KeyPackageRelays...
|
||||
val synchronizationFilter = SynchronizationFilter(
|
||||
authors = profiles.map { it.publicKey }.toTypedArray(),
|
||||
kinds = arrayOf(
|
||||
KeyPackageEvent.KIND
|
||||
)
|
||||
)
|
||||
|
||||
nostrRepository.queueNegentropySynchronizeRequest(
|
||||
Relays.DefaultDMRelayList.map { normalizedRelayUrl ->
|
||||
NegentropySynchronizeRequest(
|
||||
id = NegentropySynchronizeRequest.computeId(
|
||||
relayURL = normalizedRelayUrl.url,
|
||||
synchronizationFilter = synchronizationFilter
|
||||
),
|
||||
purpose = "key-packages",
|
||||
synchronizationFilter = synchronizationFilter,
|
||||
relayURL = normalizedRelayUrl.url,
|
||||
level = 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
SelectChatRoomMembersViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
name = name,
|
||||
description = description,
|
||||
initialSelectChatRoomMembersUIState = initialSelectChatRoomMembersUIState,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.Profile
|
||||
|
||||
sealed interface SelectChatRoomMembersUIState {
|
||||
data class Loaded(
|
||||
val profiles: List<Profile>
|
||||
): SelectChatRoomMembersUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): SelectChatRoomMembersUIState
|
||||
|
||||
data object Loading: SelectChatRoomMembersUIState
|
||||
}
|
||||
Reference in New Issue
Block a user