From 9ac4bcbee39b93777c35c55d2f9bc1fe04fba52f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:54:12 +0200 Subject: [PATCH] feat(frost): show a whole batch on the signing screen, and say so in the chat Phase 5 of docs/frost-batch-signing.md. The screen renders every event of a batch, and the transcript says how many there are. ## The approval gate The argument for one approval rather than one per event -- in FrostSigningManager's own header -- only holds if the member can see everything they are agreeing to. Two gates enforce that, and neither touches "Don't sign". - Every event has to be readable. `readable` compares the events that rendered against the items the session holds, so a batch with one unreadable element offers no Sign button at all rather than a Sign button for the ones that worked. A batch is all-or-nothing: agreeing to the two that rendered would be agreeing to the third as well. - A batch's Sign button waits until the list has been read to the end. A batch can hide an event below the fold in a way one event cannot -- what is off-screen is not further detail about the thing on screen, it is a different thing the member would also be signing. Only for k>1: a single event's screen behaves exactly as it did. Declining stays enabled through both. A member who cannot check what they are being asked to sign should still be able to say no, and saying nothing is indistinguishable from a phone in a pocket, which leaves the group waiting. ## Rendering WhatIsBeingSigned takes the list and the count it expects. Each event is still described as the thing it is -- a dialect, an artifact, a chapter -- by the extracted OneThingBeingSigned; the header counts them and the closing sentence about the group's key is said once for the batch rather than once per event. ## Transcript No new ChatMessage types, and no edits to FROST_TYPES, FROST_SETTLEMENTS or FROST_REQUEST_FULFILMENTS -- one line per member per step still describes what happened, whatever k is. Only the wording gains the number, because each of those lines describes work that covered the whole batch: "signed their part of all 3 events", "combined the parts into the group's 3 signatures", "asked the group to sign 3 events". At k=1 every line is byte-identical to before. 356 jvmTest and 224 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 36 ++++++-- .../ui/composable/FrostSigningScreen.kt | 91 ++++++++++++++++--- 2 files changed, 103 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index f44508a2..28521d5e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -1486,15 +1486,20 @@ object FrostSigningManager { ) } - private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) = + private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) { + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_STARTED, - content = "asked the group to sign something with its shared key. It takes " + - "${session.threshold} of ${session.participantCount} members to do it.", + content = "asked the group to sign " + + (if (count > 1) "$count events" else "something") + + " with its shared key. It takes ${session.threshold} of " + + "${session.participantCount} members to do it.", actor = session.coordinatorPublicKey ) + } /** * Tells the group's chat that this session is waiting on the reader. @@ -1511,12 +1516,15 @@ object FrostSigningManager { update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) } + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_APPROVAL_NEEDED, - content = "Your approval is needed to sign with the group's shared key. Nothing " + - "has been published from this device yet.", + content = "Your approval is needed to sign " + + (if (count > 1) "$count events " else "") + + "with the group's shared key. Nothing has been published from this device yet.", actor = session.userPublicKey ) } @@ -1535,19 +1543,29 @@ object FrostSigningManager { kind: Kind, actor: HexKey ) { + // A batch is still one line per member per step -- what changes is the + // number in it. Every one of these lines describes work that covered the + // whole batch, so saying so is the difference between a member reading + // "signed their part" and knowing what they signed. + val count = database.frostSigningSessionDao().countItems(session.id) + val batch = count > 1 + val (messageType, content) = when (kind) { FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to - "offered to help sign, sending the one-time value their signature needs." + "offered to help sign, sending the one-time " + + (if (batch) "values their signatures need." else "value their signature needs.") FrostSigningEvents.SIGNER_SET -> ChatMessage.TYPE_FROST_SIGNER_SET to "chose who is signing and combined their one-time values." FrostSigningEvents.PARTIAL_SIGNATURE -> ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE to - "signed their part. On its own it proves nothing; combined with the " + - "others it is the group's signature." + "signed their part" + (if (batch) " of all $count events" else "") + + ". On its own it proves nothing; combined with the others it is " + + "the group's " + (if (batch) "signatures." else "signature.") FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to - "combined the parts into the group's signature." + "combined the parts into the group's " + + (if (batch) "$count signatures." else "signature.") // PROPOSAL and FAILURE are announced by the code that acts on them -- // both say more than the message itself carries. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 2480394e..ce1b62a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -138,15 +138,25 @@ fun FrostSigningScreen( // flash an error on the way in. val session = state.session ?: return@Scaffold Loading(padding) + val proposed = frostSigningViewModel.proposedEvents(state.items) + + // Every event of the batch has to be readable before any of it can + // be signed. A member cannot check what they cannot see, and a + // batch is all-or-nothing: agreeing to the two that rendered would + // be agreeing to the third as well. + val readable = state.items.isNotEmpty() && proposed.size == state.items.size + + val scrollState = rememberScrollState() + Column( modifier = Modifier .padding(padding) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { - WhatIsBeingSigned(frostSigningViewModel.proposedEvents(state.items).firstOrNull()) + WhatIsBeingSigned(proposed, state.items.size) HorizontalDivider() @@ -234,17 +244,38 @@ fun FrostSigningScreen( Text( text = "Nothing has been published from this device yet. Signing " + - "puts your share behind this event; it cannot be taken back.", + "puts your share behind " + + (if (proposed.size > 1) "all ${proposed.size} of these events" else "this event") + + "; it cannot be taken back.", style = MaterialTheme.typography.bodySmall ) + // A batch can hide an event below the fold in a way one + // event cannot: what is off-screen is not further detail + // about the thing on screen, it is a different thing the + // member would also be signing. So a batch's Sign button + // waits until the list has been read to the end. maxValue + // is Int.MAX_VALUE until the first layout, and 0 when + // everything already fits. + val seenEverything = proposed.size <= 1 || + (scrollState.maxValue != Int.MAX_VALUE && scrollState.value >= scrollState.maxValue) + + if (readable && !seenEverything) { + Text( + text = "Read to the end of the list to sign.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { Button( - enabled = !frostSigningViewModel.isActionPending.value, + enabled = readable && seenEverything && + !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.approve(onNavigateBack) } ) { Icon(Icons.Default.Draw, contentDescription = null) @@ -252,6 +283,11 @@ fun FrostSigningScreen( Text("Sign") } + // Declining stays available whatever the screen could + // not show. A member who cannot check what they are + // being asked to sign should still be able to say no, + // and saying nothing is indistinguishable from a phone + // in a pocket -- which leaves the group waiting. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error @@ -289,16 +325,48 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * refusing to describe an event is better than describing it wrongly. */ @Composable -private fun WhatIsBeingSigned(event: Event?) { - if (event == null) { +private fun WhatIsBeingSigned(events: List, expected: Int) { + if (expected == 0 || events.size != expected) { Text( - text = "This session's event could not be read, so there is nothing to check " + - "before signing. Don't sign it.", + text = if (expected <= 1) { + "This session's event could not be read, so there is nothing to check " + + "before signing. Don't sign it." + } else { + "Only ${events.size} of this session's $expected events could be read, so " + + "there is no way to check what you would be signing. Don't sign it." + }, color = MaterialTheme.colorScheme.error ) return } + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + if (events.size > 1) { + Text( + text = "${events.size} events, signed together", + style = MaterialTheme.typography.labelMedium + ) + } + + events.forEachIndexed { index, event -> + if (index > 0) HorizontalDivider() + + OneThingBeingSigned(event) + } + + Text( + text = "Signed by the group, not by you. Once enough members sign, " + + (if (events.size > 1) "these are" else "this is") + + " published under the group's shared key.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** One event of the batch, described as the thing it is. */ +@Composable +private fun OneThingBeingSigned(event: Event) { val (label, detail) = when (event.kind) { DialectEvent.KIND -> "New dialect" to DialectEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig @@ -337,13 +405,6 @@ private fun WhatIsBeingSigned(event: Event?) { Text(text = label, style = MaterialTheme.typography.labelMedium) Text(text = detail, style = MaterialTheme.typography.titleMedium) - - Text( - text = "Signed by the group, not by you. Once enough members sign, this is " + - "published under the group's shared key.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } }