From e15d31b8325c82ac68f4f5a5330ab924e303e5f7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:16:58 +0200 Subject: [PATCH] feat(subgroups): ask a group to adopt this one, and accept the offer when it comes Phase 5 of docs/subgroups-multiple-parents.md, the UI. Phases 1-4 were dark; this is the flag, and it is the button. A group can now be given a second parent from the app. **Two affordances, on the child's own detail screen, under the parent cards.** Both belong there and not on the adopting group's screen: the child is what acquires the relationship, its quorum is what decides, and it mirrors the parent screen's existing "add subgroup". `SelectAdoptingGroupScreen` is the only new screen, because the acceptance needs none -- by then there is something to accept and a card does it. Its list is the **profile's own rooms** and cannot be anything else: a certificate is proposed inside the adopting group, so a candidate has to be a room the key in the chair can act in. `adoptingCandidatesFor` filters through `refuseAdoption` itself rather than through a copy of its rules, so screen and manager cannot drift. An empty list is a fact about this member rather than a failure, and the empty state says the reason outright -- a group cannot be offered as a parent by somebody who is not in it, so the remedy is a person rather than a button. **Tapping a row asks; it does not adopt, and the words have to carry that.** The snackbar says "asked that group to adopt this one", and the screen hands straight back to the room rather than going anywhere new. The offer card then says a group is a subgroup of another *only once its own quorum says so*, because rendered as a fact about the lineage it would be the consent gap reopened from the other end -- the one Phase 3 closed in the transcript. **The accept button is offered to whoever opens the screen and can act**, not only to the member who asked, for the reason docs/subgroups.md gives for the ritual screen's rung: every step after a ceremony is open to any admin holding a share. After it, the screen reloads rather than crossing the offer off -- the offer leaves the list when the state is signed and filed, which is a quorum away, so the card stays until the group has actually agreed. `canAddParent` wants a key state as well as a share, which `canAddSubgroup` does not: the certificate carries this group's threshold key and the acceptance re-announces that very state, so a room that has agreed nothing has neither half to work with. Hidden rather than disabled, the way the subgroup entry is. The picker is a pushed screen, so it takes an `onNavigateBack` and puts `NavigateBackButton` in its app bar's leading slot -- the rule `CLAUDE.md` gained while this was still a note, and the desktop target has neither a system back nor an edge swipe to fall back on. `offersTo` reads every certificate this device holds in **any** room rather than asking this one, because a certificate about this group is signed in the other group's room and filed against it -- asking this room would never find it. Each is checked by `classify`, and parents already in the lineage drop out, since an offer that has been accepted is not an offer. Ten strings in the catalogue in sentence case, spacing from `MaterialTheme.spacing`, colour from roles, `Decorative` on every icon beside a label, and the notifier read outside the handler with the caller's scope -- an acceptance is shown while the screen reloads around it. `inComparableGroups` stays private to `ChatRoomDetailScreen`: the upstream commit that moved it to the key extensions is in the fork's tree and not this one, and a second copy to add three spaces to an id is not worth having. **What is reachable and what is not.** Adding a parent and accepting one are both wired. `SubgroupManager.proposeDisavowal` is built, tested and has no button -- dropping a parent is a destructive-looking act that wants a confirmation step, and it belongs in its own change rather than bolted onto this one. 454 common tests, 962 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 10 + .../repository/DatabaseChatRepository.kt | 81 ++++++++ .../compose/managers/SubgroupManager.kt | 27 +++ .../compose/repository/ChatRepository.kt | 68 ++++++ .../ui/composable/ChatRoomDetailScreen.kt | 106 ++++++++++ .../composable/SelectAdoptingGroupScreen.kt | 195 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 14 ++ .../routes/SelectAdoptingGroupRoute.kt | 22 ++ .../ui/view/model/ChatRoomDetailViewModel.kt | 41 ++++ .../model/SelectAdoptingGroupViewModel.kt | 138 +++++++++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 23 +++ .../view/state/SelectAdoptingGroupUIState.kt | 24 +++ 12 files changed, 749 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..771819ef 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -491,4 +491,14 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Who adopts this group? + Which group should adopt this one? + No group you administer can adopt this one. A group can only be offered as a parent by somebody who is in it. + Asked that group to adopt this one + Parent groups + Add a parent group + %1$s has offered to adopt this group + Accept as a parent + A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them. + Asked this group to accept that parent diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 915fdec8..3463a4e5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -111,6 +111,87 @@ class DatabaseChatRepository( emptyList() } + override suspend fun adoptionOffersFor(chatRoomId: String): List = try { + SubgroupManager.offersTo(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading the adoption offers for $chatRoomId", e) + emptyList() + } + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = try { + SubgroupManager.adoptingCandidatesFor( + database = database, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error reading the groups that could adopt $chatRoomId", e) + emptyList() + } + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = try { + val adopting = database.chatRoomDao().findChatRoomById(adoptingChatRoomId) + ?: return "Couldn't load that group" + + SubgroupManager.refuseAdoption( + database = database, + adoptingRoom = adopting, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error checking whether $adoptingChatRoomId can adopt $chatRoomId", e) + // A refusal rather than a pass. A guard that could not run has not said + // yes, and the manager checks again anyway. + "Couldn't check whether that group can adopt this one" + } + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = try { + val adopting = checkNotNull(database.chatRoomDao().findChatRoomById(adoptingChatRoomId)) + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAdoption( + database = database, + adoptingRoom = adopting, + childRoom = child, + userPublicKey = userPublicKey + ) + true + } catch (e: Throwable) { + logger.e("Error asking $adoptingChatRoomId to adopt $chatRoomId", e) + false + } + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = try { + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAcceptance( + database = database, + childRoom = child, + userPublicKey = userPublicKey, + parentChatRoomId = parentChatRoomId + ) + true + } catch (e: Throwable) { + logger.e("Error asking $chatRoomId to accept $parentChatRoomId as a parent", e) + false + } + override suspend fun parentsOf(chatRoomId: String): List = try { SubgroupManager.parentsOf(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 0d68a0f7..a10c237c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -480,6 +480,33 @@ object SubgroupManager { return false } + /** + * Groups that have offered to adopt [chatRoomId] and are not its parents yet, + * newest offer first. + * + * Read off every certificate this device holds, in any room, rather than off + * the room this one is -- a certificate about this group is signed in the + * *other* group's room, so it is filed against that room and would never be + * found by asking this one. Each is checked by [classify], which verifies the + * signature against the group it names and needs no lookup to do it. + * + * Already-parents are dropped, because an offer that has been accepted is + * not an offer any more; the lineage is where that shows. + */ + suspend fun offersTo(database: MantraDatabase, chatRoomId: String): List { + val accepted = parentsOf(database, chatRoomId).map { it.lowercase() }.toSet() + + return database.groupSignedEventDao() + .getByKind(SubgroupBirthCertificateEvent.KIND) + .sortedByDescending { it.createdAt } + .mapNotNull { signed -> + (classify(chatRoomId, signed.toEvent()) + as? ArrivingCertificate.OfferToUs)?.parentChatRoomId + } + .filterNot { it.lowercase() in accepted } + .distinctBy { it.lowercase() } + } + /** * The groups this profile could offer [childChatRoomId] to, in room order. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 9453a9e7..319e3782 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -116,6 +116,49 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? + /** + * Groups that have offered to adopt this room and are not its parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption needs. + */ + suspend fun adoptionOffersFor(chatRoomId: String): List + + /** The groups this profile could offer [chatRoomId] to, for the picker. */ + suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List + + /** Why [adoptingChatRoomId] cannot adopt [chatRoomId], or null when it can. */ + suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child. + * + * The offer, and only that: the child's own quorum answers it with + * [acceptParent], and nothing is adopted until it does. + */ + suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean + + /** + * Asks this room's quorum to accept [parentChatRoomId], by re-announcing its + * key state with that parentage added to whatever it already claims. + */ + suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List /** @@ -307,6 +350,31 @@ interface ChatRepository { return null } + override suspend fun adoptionOffersFor(chatRoomId: String): List = emptyList() + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = emptyList() + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = "No database" + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = false + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = false + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 9c9efbae..b490e05d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -88,12 +88,18 @@ import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.parent_group +import mantra.composeapp.generated.resources.accept_as_a_parent +import mantra.composeapp.generated.resources.add_a_parent_group +import mantra.composeapp.generated.resources.a_group_is_a_subgroup_once_its_own_quorum_says_so +import mantra.composeapp.generated.resources.asked_this_group_to_accept_that_parent +import mantra.composeapp.generated.resources.s_has_offered_to_adopt_this_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member import mantra.composeapp.generated.resources.certified_not_yet_created import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group import mantra.composeapp.generated.resources.add_subgroup import mantra.composeapp.generated.resources.subgroups +import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute @@ -166,6 +172,13 @@ fun ChatRoomDetailScreen( ) ) + // Both read here because both are composable and an onClick lambda is not. + // The notifier takes the caller's scope on purpose: an acceptance is shown + // while the screen reloads around it, and a message launched in a departing + // composable's scope would be cancelled with it. + val notify = rememberNotifier(rememberCoroutineScope()) + val acceptedParent = stringResource(Res.string.asked_this_group_to_accept_that_parent) + ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState -> when (val chatRoomDetailUIState = uiState) { is ChatRoomDetailUIState.Error -> { @@ -310,6 +323,99 @@ fun ChatRoomDetailScreen( } } + // An offer, and it has to read like one. The other + // group's quorum has signed; this group has not, and + // until it does the lineage is unchanged. Rendered as + // a fact about the lineage it would be the consent gap + // reopened from the other end. + chatRoomDetailUIState.adoptionOffers.forEach { offeringChatRoomId -> + item(key = "offer-$offeringChatRoomId") { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = stringResource( + Res.string.s_has_offered_to_adopt_this_group, + offeringChatRoomId.take(16) + ) + ) + }, + supportingContent = { + Text( + text = stringResource( + Res.string + .a_group_is_a_subgroup_once_its_own_quorum_says_so + ), + style = MaterialTheme.typography.bodySmall + ) + }, + trailingContent = { + // Offered to whoever opens the + // screen and can act, not only to + // the member who asked -- every + // step after the ceremony is open + // to any admin holding a share. + if (chatRoomDetailUIState.canAddParent) { + TextButton( + onClick = { + chatRoomDetailViewModel.acceptParent( + offeringChatRoomId + ) { message -> + notify(message ?: acceptedParent) + } + } + ) { + Text( + stringResource( + Res.string.accept_as_a_parent + ) + ) + } + } + } + ) + } + } + } + + // Hidden rather than disabled, the way the subgroup + // entry is: a room with no key state has neither half + // of an adoption to work with, and a device with no + // share cannot sign either of them. + if (chatRoomDetailUIState.canAddParent) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectAdoptingGroupRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier + .width(MaterialTheme.spacing.relatedGap) + ) + + Text(stringResource(Res.string.add_a_parent_group)) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt new file mode 100644 index 00000000..16313ef2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt @@ -0,0 +1,195 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.launch +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one +import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one +import mantra.composeapp.generated.resources.which_group_should_adopt_this_one +import mantra.composeapp.generated.resources.who_adopts_this_group +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SelectAdoptingGroupViewModel +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * Tapping a row asks that group's quorum to certify this room as its child. That + * is an **offer** and nothing more: this room becomes their subgroup only once + * its own quorum re-announces its key state naming them, which is a card back on + * the room's own screen. So this hands back rather than going anywhere new, and + * the snackbar says what was asked rather than what was done. + * + * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for + * why it can be nothing else -- so empty is an ordinary answer and the empty + * state explains it rather than apologising. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectAdoptingGroupScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + onNavigateBack: () -> Unit, +) { + val viewModel: SelectAdoptingGroupViewModel = viewModel( + factory = SelectAdoptingGroupViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + ) + + LaunchedEffect(chatRoomId) { viewModel.initiate() } + + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + // Read outside the handler: both of these are composable and an onClick + // lambda is not. The notifier takes the caller's scope because the message is + // shown as this screen navigates away, and one launched in the departing + // composable's scope would be cancelled with it. + val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one) + + ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { + is SelectAdoptingGroupUIState.Error -> ErrorState( + message = state.message, + // Retrying reloads the group and the candidates, which is what + // failed. + onRetry = { viewModel.initiate() } + ) + + SelectAdoptingGroupUIState.Loading -> LoadingDataIndicator() + + is SelectAdoptingGroupUIState.Loaded -> Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.who_adopts_this_group)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + if (state.candidates.isEmpty()) { + item { + // The message carries the reason, because the + // reason is the whole content of this absence: a + // group cannot be offered as a parent by somebody + // who is not in it, so the remedy is a person. + EmptyState( + message = stringResource( + Res.string.no_group_here_can_adopt_this_one + ) + ) + } + } else { + item { + Text( + text = stringResource( + Res.string.which_group_should_adopt_this_one + ), + style = MaterialTheme.typography.labelMedium + ) + } + + items( + items = state.candidates, + key = { candidate -> candidate.chatRoom.id } + ) { candidate -> + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + viewModel.offerTo(candidate.chatRoom.id) { refusal -> + scope.launch { + snackbarHostState.showSnackbar(refusal ?: asked) + } + if (refusal == null) onNavigateBack() + } + } + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = candidate.chatRoom.subject + ?: candidate.chatRoom.id.take(16), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = candidate.chatRoom.id.take(16), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 92f022d0..ae13c349 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -99,7 +99,9 @@ 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.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.SelectAdoptingGroupScreen import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute @@ -599,6 +601,18 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectAdoptingGroupScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt new file mode 100644 index 00000000..5c018ebc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings, and the only one that needs a screen + * of its own: the acceptance is a card on the child's own detail screen, because + * by then there is something to accept. + * + * Carries only the child's id. The candidates are not on the route and must not + * be: they are the rooms the **profile in the chair** can act in, which a route + * cannot know and which changes while the screen is open -- see + * `SubgroupManager.adoptingCandidatesFor`. + */ +@Serializable +data class SelectAdoptingGroupRoute( + val activeUserPublicKey: String, + val chatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 4500b9ac..9d7b7ade 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -84,11 +84,52 @@ class ChatRoomDetailViewModel( parentChatRoomIds = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), + adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), + // A key state as well as a share: the certificate carries + // this group's threshold key, and the acceptance re-announces + // that very state, so a room that has agreed nothing has + // neither half to work with. + canAddParent = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.groupKeyState(chatRoomId) != null && + chatRepository.canSign(chatRoomId), ) } } } + /** + * Asks this group's quorum to accept [parentChatRoomId] as a parent. + * + * The acceptance, and the half that decides: the certificate is an offer + * until a quorum of *this* group has re-announced its key state naming them. + * `SubgroupManager.proposeAcceptance` carries the whole resulting set, so a + * group that already has a parent keeps it. + * + * [onDone] is called with null on success and a message otherwise, so the + * caller can show it from a scope that outlives this screen. + */ + fun acceptParent(parentChatRoomId: HexKey, onDone: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val accepted = chatRepository.acceptParent( + chatRoomId = chatRoomId, + parentChatRoomId = parentChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (!accepted) { + logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId") + onDone("Couldn't ask this group to accept that parent. Please try again.") + return@launch + } + + // The offer leaves the list only when the state is signed and filed, + // which is a quorum away -- so this reloads rather than crossing it + // off, and the card stays until the group has actually agreed. + initiateChatRoomDetail() + onDone(null) + } + } + fun leaveGroup( localChatRoom: LocalChatRoom, onPopBackToRoute: (Route) -> Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt new file mode 100644 index 00000000..a46ef417 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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 co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings. Confirming proposes a birth + * certificate in the **chosen** group's room, and that is all it does: the + * certificate is an **offer**, and this room is not that group's subgroup until + * its own quorum re-announces its key state naming them. So this screen hands + * back to the room it came from rather than anywhere new -- the acceptance is a + * card there, and the answer belongs to whoever opens it. + * + * ### The candidates cannot be anything but this profile's own rooms + * + * A certificate is proposed *inside* the adopting group, so a candidate has to be + * a room the key in the chair can act in: a Marmot room this profile administers + * and holds a share of, which is not this room, is not already a parent, has not + * already offered, and is not already beneath this one. Every one of those is + * `SubgroupManager.refuseAdoption`'s, run through that function rather than + * through a copy of its rules, so the screen and the manager cannot drift. + * + * An empty list is therefore a fact about this member rather than a failure, and + * the screen says so: a group cannot be offered as a parent by somebody who is + * not in it, and the remedy is a person rather than a button. + */ +class SelectAdoptingGroupViewModel( + val activeUserPublicKey: HexKey, + val chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var uiState: SelectAdoptingGroupUIState by mutableStateOf(initialUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val childRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (childRoom == null) { + uiState = SelectAdoptingGroupUIState.Error("Couldn't load this group") + return@launch + } + + uiState = SelectAdoptingGroupUIState.Loaded( + childRoom = childRoom, + candidates = chatRepository.adoptingCandidatesFor( + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey + ) + ) + } + } + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child, then + * hands back. + * + * Checked again here rather than trusted from the list, because the screen + * may have been open a while and a screen not drawing something is not a + * guard. The manager checks a third time; this one exists to say *why* rather + * than to throw. + */ + fun offerTo(adoptingChatRoomId: String, onDone: (String?) -> Unit) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val refusal = chatRepository.refuseAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (refusal != null) { + isActionPending.value = false + onDone(refusal) + return@launch + } + + val proposed = chatRepository.proposeAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + isActionPending.value = false + + if (!proposed) { + logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId") + onDone("Couldn't ask that group to adopt this one. Please try again.") + return@launch + } + + onDone(null) + } + } + + companion object { + private const val TAG = "SelectAdoptingGroupViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectAdoptingGroupViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 7082d6d8..d357797e 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -64,6 +64,29 @@ sealed interface ChatRoomDetailUIState { * missing and what to do about it. */ val canAddSubgroup: Boolean = false, + /** + * Groups that have offered to adopt this one and are not parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption + * needs, showing up here as a list that is empty for everybody else. + * + * Each is verified: the signature was checked against the group it names, + * which needs no lookup. What is unverified is nothing; what is + * *unsettled* is everything, because an offer is not a lineage until this + * group's own quorum says so. + */ + val adoptionOffers: List = emptyList(), + /** + * Whether this device could put a parent to this group at all. + * + * The room has to be a Marmot one with a key state -- the certificate + * carries the child's threshold key and the acceptance re-announces that + * state -- and this device has to hold a share, because both halves are + * signatures by this group. The rest of the refusals are the picker's, + * where they can name which group and why. + */ + val canAddParent: Boolean = false, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt new file mode 100644 index 00000000..9255519b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface SelectAdoptingGroupUIState { + data class Loaded( + val childRoom: LocalChatRoom, + /** + * The groups this profile could ask, in room order. + * + * **The profile's own rooms, and nothing else is possible.** A + * certificate is proposed *inside* the adopting group, so a candidate has + * to be a room the key in the chair can already act in. Empty is a fact + * about this member rather than a failure -- a group cannot be offered as + * a parent by somebody who is not in it, and the remedy is a person + * rather than a button. + */ + val candidates: List = emptyList(), + ): SelectAdoptingGroupUIState + + data class Error(val message: String): SelectAdoptingGroupUIState + + data object Loading: SelectAdoptingGroupUIState +}