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:
Kgothatso Ngako
2026-08-29 16:14:37 +02:00
parent 539babbfce
commit c5c4bc9ed6
7 changed files with 773 additions and 136 deletions

View File

@@ -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 = {}
)
}
}

View File

@@ -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 = {}
)
}
}
}

View File

@@ -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
}
}
}
)

View File

@@ -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()

View File

@@ -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()
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
}