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