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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 18:16:58 +02:00
parent b54ff61eb6
commit e15d31b832
12 changed files with 749 additions and 0 deletions

View File

@@ -491,4 +491,14 @@
<string name="you_already_have_a_chat_with_s">You already have a chat with %1$s.</string>
<string name="checking_whether_s_can_receive_messages_here">Checking whether %1$s can receive messages here.</string>
<string name="s_hasn_t_set_up_messaging_on_mantra_yet">%1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.</string>
<string name="who_adopts_this_group">Who adopts this group?</string>
<string name="which_group_should_adopt_this_one">Which group should adopt this one?</string>
<string name="no_group_here_can_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.</string>
<string name="asked_that_group_to_adopt_this_one">Asked that group to adopt this one</string>
<string name="parent_groups">Parent groups</string>
<string name="add_a_parent_group">Add a parent group</string>
<string name="s_has_offered_to_adopt_this_group">%1$s has offered to adopt this group</string>
<string name="accept_as_a_parent">Accept as a parent</string>
<string name="a_group_is_a_subgroup_once_its_own_quorum_says_so">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.</string>
<string name="asked_this_group_to_accept_that_parent">Asked this group to accept that parent</string>
</resources>

View File

@@ -111,6 +111,87 @@ class DatabaseChatRepository(
emptyList()
}
override suspend fun adoptionOffersFor(chatRoomId: String): List<HexKey> = 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<LocalChatRoom> = 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<HexKey> = try {
SubgroupManager.parentsOf(database, chatRoomId)
} catch (e: Throwable) {

View File

@@ -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<HexKey> {
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.
*

View File

@@ -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<HexKey>
/** The groups this profile could offer [chatRoomId] to, for the picker. */
suspend fun adoptingCandidatesFor(
chatRoomId: String,
userPublicKey: HexKey
): List<LocalChatRoom>
/** 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<Participant>
/**
@@ -307,6 +350,31 @@ interface ChatRepository {
return null
}
override suspend fun adoptionOffersFor(chatRoomId: String): List<HexKey> = emptyList()
override suspend fun adoptingCandidatesFor(
chatRoomId: String,
userPublicKey: HexKey
): List<LocalChatRoom> = 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<Participant> {
TODO("Not yet implemented")
}

View File

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

View File

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

View File

@@ -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<SelectAdoptingGroupRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SelectAdoptingGroupRoute>()
SelectAdoptingGroupScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
chatRepository = databaseChatRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<DkgRitualRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgRitualRoute>()

View File

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

View File

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

View File

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

View File

@@ -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<HexKey> = 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(

View File

@@ -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<LocalChatRoom> = emptyList(),
): SelectAdoptingGroupUIState
data class Error(val message: String): SelectAdoptingGroupUIState
data object Loading: SelectAdoptingGroupUIState
}