feat(subgroups): put the ceremony that needs you at the bottom of the chat

A NIP-17 room already showed the standing "waiting for your signature" notice
under the newest message -- `ProposalsAwaitingYouNotice` is transport-agnostic and
reads `FrostSigningSession` by room. What it never covered is the other thing a
room can owe somebody, which in a NIP-17 room is the main thing: a ceremony.

That gap matters more here than the signing one does. A ChillDKG cannot finish
until **every** member has taken part, so one member not finding their request
stalls everyone indefinitely -- and the only way to find it was to scroll the
transcript to its request line, past whatever else the room has been used for.
Three subgroups on the connected devices sat at 1 of 3 host keys for exactly that
reason.

`CeremoniesAwaitingYouNotice` sits beside the signing one, first in the reversed
layout so a room owing both puts the ceremony nearest the composer -- until a
ceremony finishes there is no key to sign anything with.

**It covers two different kinds of owing, and the second has no gate behind it.**
A participant is owed an approval, read through the same `pendingApproval` the
ritual screen uses so the two cannot disagree. The member who *opened* it is owed
something the protocol has no gate for: a ceremony reaching COMPLETE finishes
nothing on its own -- the group has a key and somebody still has to get its state
signed and create the room -- and that somebody is whoever opened it. Nothing else
in the app would ever say so, which is what the coordinator was missing.

`roomAwaitingCreation` is how it knows when to stop: the room a finished ceremony's
key derives either exists or does not. Asking that rather than keeping a flag means
the notice cannot become permanent furniture in every room that has ever held a
ceremony.

**It reads every ceremony in the room, not the newest.** A room holds more than one
the moment a subgroup's admins are the whole group, and the one that wants you is
routinely not the one that happened last -- that is what buried 2.0 and 2.1. The
notice opens the ceremony it names, by session id, through the same
`onOpenSharedKey` the transcript's own lines use since `0b65d702`.

Unlike the signing notice it opens the ceremony rather than a queue: there is no
queue of ceremonies, and with several the count is shown and the newest opened.

Eight strings in the catalogue in sentence case, each step worded the way its
approval screen words it so a member is not asked twice in two vocabularies.
`dkgRepository` is threaded to `ChatMessageListViewModel` through the messaging
screen, the home pane and the nav host.

397 common tests, 718 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-09-09 02:14:11 +02:00
parent a6bb811b03
commit 5c893ab108
10 changed files with 273 additions and 4 deletions

View File

@@ -63,6 +63,16 @@ interface DkgSessionDao {
)
fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow<DkgSession?>
/**
* Every ceremony this room holds, newest first.
*
* A room holds more than one the moment a subgroup's admins are the whole
* group, so the standing "this needs you" notice in the transcript cannot ask
* about "the room's ceremony" -- it has to look at all of them and say which.
*/
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>>
/**
* Every ceremony this device came out of holding a share, newest first.
*

View File

@@ -3,6 +3,7 @@ package press.mantra.compose.database.repository
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
@@ -31,6 +32,19 @@ class DatabaseDkgRepository(
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
database.dkgSessionDao().observeSessionById(sessionId)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>> =
database.dkgSessionDao().observeSessionsForChatRoom(chatRoomId)
override suspend fun roomAwaitingCreation(session: DkgSession): String? {
if (session.stage != DkgRitualStage.COMPLETE) return null
val roomId = session.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
?: return null
return roomId.takeIf { database.chatRoomDao().findChatRoomById(it) == null }
}
override fun observeLatestSessionForChatRoom(
chatRoomId: String,
parentChatRoomId: String?,

View File

@@ -44,6 +44,26 @@ interface DkgRepository {
*/
fun observeSessionById(sessionId: String): Flow<DkgSession?>
/**
* Every ceremony a room holds, newest first.
*
* For the transcript's standing notice, which has to say what a room owes
* this member without knowing in advance how many ceremonies are in it.
*/
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>>
/**
* The room a finished ceremony's key derives, if this device does not hold it
* yet -- or null once it does, or while there is no key.
*
* What tells the coordinator they still have work. A ceremony reaching
* COMPLETE is not the end of anything on its own: the group has a key and
* somebody still has to get its state signed and make the room. Once that
* room exists there is nothing left to do, and asking whether it exists is
* how this knows without keeping a flag that could be wrong.
*/
suspend fun roomAwaitingCreation(session: DkgSession): String?
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
/**
@@ -178,6 +198,11 @@ interface DkgRepository {
override fun observeSessionById(sessionId: String): Flow<DkgSession?> = flowOf(null)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>> =
flowOf(emptyList())
override suspend fun roomAwaitingCreation(session: DkgSession): String? = null
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
override suspend fun proposeRitual(

View File

@@ -43,6 +43,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
@@ -82,6 +83,7 @@ fun ChatRoomMessagingScreen(
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
dkgRepository: DkgRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
@@ -119,7 +121,8 @@ fun ChatRoomMessagingScreen(
localChatRoom = chatRoomDetailUIState.localChatRoom,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
frostSigningRepository = frostSigningRepository,
dkgRepository = dkgRepository
)
)
@@ -486,6 +489,7 @@ private fun ChatRoomMessagingScreenPreview() {
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)

View File

@@ -65,6 +65,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import androidx.compose.ui.Alignment
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import press.mantra.compose.ui.composable.widgets.Decorative
@@ -90,6 +91,7 @@ fun HomeScreen(
// required parameter rather than a nullable one because a home screen that silently
// loses its detail pane on a desktop is a worse failure than a compile error.
frostSigningRepository: FrostSigningRepository,
dkgRepository: DkgRepository,
) {
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
@@ -291,6 +293,7 @@ fun HomeScreen(
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
dkgRepository = dkgRepository,
// "Replace what is on screen" is a route push on a
// phone and a change of selection here. It fires when
// a conversation that did not exist yet has just been
@@ -381,6 +384,7 @@ It has survived not only five centuries, but also the leap into electronic types
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
)
}
}

View File

@@ -734,6 +734,7 @@ fun MantraNavHost(
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
dkgRepository = databaseDkgRepository,
)
}
composable<BlankRoute> {
@@ -834,6 +835,7 @@ fun MantraNavHost(
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
dkgRepository = databaseDkgRepository,
onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
navController.navigate(
route = chatRoomDetailRoute

View File

@@ -17,6 +17,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import mantra.composeapp.generated.resources.open
import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up
import mantra.composeapp.generated.resources.check_the_combined_result
import mantra.composeapp.generated.resources.send_your_contribution_to_the_key
import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key
import mantra.composeapp.generated.resources.the_subgroups_key_ceremony_needs_you
import mantra.composeapp.generated.resources.the_key_ceremony_needs_you
import mantra.composeapp.generated.resources.ceremonies_need_you
import press.mantra.compose.database.model.types.DkgApprovalStep
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
@@ -161,6 +170,7 @@ fun ChatTranscript(
// of this function and not of a lazy item that may not
// be composed at the time.
val awaitingYou = viewModel.proposalsAwaitingYou
val ceremoniesAwaitingYou = viewModel.ceremoniesAwaitingYou
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
@@ -179,6 +189,23 @@ fun ChatTranscript(
}
}
// Beside the signing notice and above it in the
// reversed layout, so a room owing both puts the
// ceremony nearest the composer. A ceremony is the
// thing everything else in the room is waiting on:
// until it finishes there is no key to sign anything
// with.
if (ceremoniesAwaitingYou.isNotEmpty()) {
item {
CeremoniesAwaitingYouNotice(
ceremonies = ceremoniesAwaitingYou,
onClick = { dkgSessionId ->
onOpenSharedKey(dkgSessionId)
}
)
}
}
item {
if (viewModel.isReceiverChatMessageRelayListMissing.value) {
Row(
@@ -526,6 +553,92 @@ fun ChatTranscript(
* of what is owed, and the queue is the screen that answers the question it
* raises -- including for the one it could not name.
*/
/**
* What a ceremony in this room still wants from this member, under the newest
* message.
*
* The counterpart to [ProposalsAwaitingYouNotice], for the other thing a room can
* be owed. It matters more in a NIP-17 room than a signing notice does, because a
* NIP-17 room is where ceremonies happen and a ceremony cannot finish until every
* member has taken part -- so one member not finding their request stalls everyone
* indefinitely, with nothing on any screen saying why.
*
* Unlike the signing notice this opens the ceremony it names rather than a queue.
* There is no queue of ceremonies to open, and naming one is honest: with several,
* the count is shown and the newest is opened, which is the one a member is most
* likely to have just been asked about.
*
* The coordinator's case has no approval gate behind it at all. A ceremony that
* has produced a key still needs its state signed and its room created, and the
* member who opened it is the one who does that -- so `roomAwaitingCreation`
* earns a place here beside the gates, and stops being asked for the moment the
* room exists.
*/
@Composable
private fun CeremoniesAwaitingYouNotice(
ceremonies: List<ChatMessageListViewModel.AwaitingCeremony>,
onClick: (dkgSessionId: String) -> Unit,
) {
val single = ceremonies.singleOrNull()
val headline = when {
single == null -> stringResource(Res.string.ceremonies_need_you, ceremonies.size)
single.isSubgroup -> stringResource(Res.string.the_subgroups_key_ceremony_needs_you)
else -> stringResource(Res.string.the_key_ceremony_needs_you)
}
// What it wants, in the words the approval screens use for the same step, so
// a member is not asked twice in two vocabularies. The gate comes first: an
// approval is owed by this member and cannot be done by anybody else, while
// finishing the setup is work the coordinator can come back to.
val detail = single?.let {
when (it.step) {
DkgApprovalStep.HOST_KEY -> stringResource(Res.string.join_the_ceremony_by_publishing_your_key)
DkgApprovalStep.ROUND_1 -> stringResource(Res.string.send_your_contribution_to_the_key)
DkgApprovalStep.ROUND_2 -> stringResource(Res.string.check_the_combined_result)
null -> stringResource(Res.string.the_group_has_its_key_finish_setting_it_up)
}
}
Card(
onClick = { onClick(ceremonies.first().sessionId) },
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space150),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Key, contentDescription = Decorative)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
) {
Text(text = headline, style = MaterialTheme.typography.labelMedium)
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Text(
text = stringResource(Res.string.open),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun ProposalsAwaitingYouNotice(
proposals: List<ChatMessageListViewModel.AwaitingProposal>,

View File

@@ -23,6 +23,9 @@ import press.mantra.compose.nostr.MemberProfileSync
import press.mantra.compose.nostr.Nip17Filters
import press.mantra.compose.nostr.Relays
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.text.ProposedEvent
@@ -41,7 +44,8 @@ class ChatMessageListViewModel(
val localChatRoom: LocalChatRoom,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository
val frostSigningRepository: FrostSigningRepository,
val dkgRepository: DkgRepository
): ViewModel() {
val logger = Logger.withTag(TAG)
var chatMessageListUIState: ChatMessageListUIState by mutableStateOf(initialChatMessageListUIState)
@@ -94,6 +98,46 @@ class ChatMessageListViewModel(
var proposalsAwaitingYou: List<AwaitingProposal> by mutableStateOf(emptyList())
private set
/**
* One ceremony in this room that still wants something from this member.
*
* The counterpart to [AwaitingProposal], for the other thing a room can be
* waiting on somebody for. A NIP-17 room is where ceremonies happen, and
* before this the only way to find one that wanted you was to scroll the
* transcript to its request line -- which for a subgroup means scrolling past
* whatever else the room has been used for.
*/
data class AwaitingCeremony(
val sessionId: String,
/** What it wants, or null when what it wants is the next step rather than a signature. */
val step: DkgApprovalStep?,
/**
* The room this ceremony's key derives, when that room does not exist
* yet and this member is the one who would make it.
*
* A ceremony reaching COMPLETE finishes nothing on its own: the group has
* a key and somebody still has to get its state signed and create the
* room. That somebody is whoever opened it, and nothing else in the app
* would ever tell them so.
*/
val roomAwaitingCreation: String?,
/** Whether this ceremony is making a subgroup, for what the notice says. */
val isSubgroup: Boolean,
)
/**
* The ceremonies in this room that still want something from this member.
*
* Every one of them, not the room's newest: a room holds more than one the
* moment a subgroup's admins are the whole group, and the one that wants you
* is routinely not the one that happened last.
*/
var ceremoniesAwaitingYou: List<AwaitingCeremony> by mutableStateOf(emptyList())
private set
/**
* Whether opening [sessionId] by itself would hide other decisions the room
* is waiting on this member for.
@@ -156,6 +200,7 @@ class ChatMessageListViewModel(
scheduleSynchronization()
observeChatRoomFeed()
observeProposalsAwaitingYou()
observeCeremoniesAwaitingYou()
askForGroupHistory()
}
@@ -192,6 +237,46 @@ class ChatMessageListViewModel(
}
}
/**
* Watches which of the room's ceremonies are waiting on this member.
*
* Two different kinds of waiting, and both belong here. A participant is owed
* an approval gate -- `pendingApproval` is the same reading the ritual screen
* makes, so the two cannot disagree. The member who *opened* it is owed
* something the protocol has no gate for: a finished ceremony still needs its
* key state signed and its room created, and nothing else in the app would
* ever say so.
*
* A ceremony whose room exists wants nothing from anybody, which is what
* `roomAwaitingCreation` is asking. That keeps the notice from becoming
* permanent furniture in every room that has ever held a ceremony.
*/
private fun observeCeremoniesAwaitingYou() {
viewModelScope.launch(Dispatchers.IO) {
dkgRepository
.observeSessionsForChatRoom(localChatRoom.chatRoom.id)
.collect { sessions ->
ceremoniesAwaitingYou = sessions
.filter { it.stage != DkgRitualStage.FAILED }
.mapNotNull { session ->
val step = dkgRepository.pendingApproval(session)
val roomAwaitingCreation = session
.takeIf { it.coordinatorPublicKey == it.userPublicKey }
?.let { dkgRepository.roomAwaitingCreation(it) }
if (step == null && roomAwaitingCreation == null) return@mapNotNull null
AwaitingCeremony(
sessionId = session.id,
step = step,
roomAwaitingCreation = roomAwaitingCreation,
isSubgroup = session.parentChatRoomId != null,
)
}
}
}
}
/**
* Ask the group for its signed record, if this device holds none of it.
*
@@ -340,7 +425,8 @@ class ChatMessageListViewModel(
localChatRoom: LocalChatRoom,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository
frostSigningRepository: FrostSigningRepository,
dkgRepository: DkgRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ChatMessageListViewModel(
@@ -348,7 +434,8 @@ class ChatMessageListViewModel(
localChatRoom = localChatRoom,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
frostSigningRepository = frostSigningRepository,
dkgRepository = dkgRepository
)
}
}