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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user