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