From dbdff55ee0f038ec912885743f2a0336f88232f6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 12:43:00 +0200 Subject: [PATCH] feat: open the proposal a transcript line is about, not the room's latest Tapping a signing line in the transcript opened whichever session the room was running, resolved by `liveSessionForChatRoom` -- the newest one not yet finished, or failing that the newest one at all. That is a guess, and it was a good one exactly as long as a room had one proposal to guess at. With a chapter and its translation open together, half the lines in the transcript led to the other proposal. The line now says which session it belongs to, so there is nothing left to guess: it carries `frostSigningSessionId` through to the route. A line written before that column opens the room's proposal list instead, which is the honest answer to a line that cannot say what it meant -- every proposal with its own state, and the reader picks -- rather than a guess dressed as an answer. That empties `FrostSigningRoute.sessionId` of its reason to be optional, so it is required, and `liveSessionForChatRoom` goes with it from the interface, the implementation and the no-op. `FrostSigningViewModel` loses its resolution step and the "This group is not signing anything right now" error underneath it -- which was never the right thing to say to somebody who had just tapped a line about a specific session. The transcript keeps doing the one job it is good at: showing a proposal as it happens, and saying whether it is still asking something of you. What it stops doing is standing in for a list of them. Co-Authored-By: Claude Opus 5 --- .../DatabaseFrostSigningRepository.kt | 9 ------- .../repository/FrostSigningRepository.kt | 12 --------- .../ui/composable/ChatRoomMessagingScreen.kt | 26 ++++++++++++++----- .../ui/composable/FrostSigningScreen.kt | 2 +- .../navigation/routes/FrostSigningRoute.kt | 19 ++++++-------- .../ui/view/model/ChatMessageListViewModel.kt | 16 ++++++++++-- .../ui/view/model/FrostSigningViewModel.kt | 20 ++++---------- docs/frost-batch-signing.md | 10 +++++++ 8 files changed, 57 insertions(+), 57 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index 4a0c514c..951bd62b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -13,7 +13,6 @@ import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession -import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.FrostSigningRepository @@ -41,14 +40,6 @@ class DatabaseFrostSigningRepository( override suspend fun getSessionById(sessionId: String): FrostSigningSession? = database.frostSigningSessionDao().getSessionById(sessionId) - override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? { - val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId) - - return sessions.firstOrNull { - it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED - } ?: sessions.firstOrNull() - } - override suspend fun canSign(chatRoomId: String): Boolean = FrostSigningManager.canSign(database, chatRoomId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 7d13801c..fbaaffbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -45,16 +45,6 @@ interface FrostSigningRepository { suspend fun getSessionById(sessionId: String): FrostSigningSession? - /** - * The session a room is currently running, or its most recent one if none is. - * - * For callers that mean "the signing going on here" without holding an id -- - * a transcript line, mostly. Prefers a live session because that is the one - * anybody tapping through wants to act on; a finished one is only what is - * left to show when there is nothing live. - */ - suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? - /** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */ suspend fun canSign(chatRoomId: String): Boolean @@ -156,8 +146,6 @@ interface FrostSigningRepository { override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null - override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null - override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun proposeSigning( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index e909ab6c..b023c243 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -48,6 +48,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme @@ -147,14 +148,25 @@ fun ChatRoomMessagingScreen( ) ) }, - onOpenSigning = { - // No session id: a chat row carries none, and the - // screen resolves the room's live one. + onOpenSigning = { sessionId -> + // A line written before chat rows named their + // session cannot say which proposal it is about, + // and the room may have several. The list is the + // honest answer: it shows all of them with their + // own state, rather than guessing at one. onNavigateToRoute.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) + if (sessionId == null) { + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } else { + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + } ) } ) 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 a62b92d1..8032eabb 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 @@ -74,7 +74,7 @@ import press.mantra.compose.ui.view.state.FrostSigningUIState fun FrostSigningScreen( activeUserPublicKey: HexKey, chatRoomId: String, - sessionId: String?, + sessionId: String, initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt index 3d7575f7..7f8e7637 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt @@ -8,20 +8,17 @@ import kotlinx.serialization.Serializable * Carries a session id rather than only a room, because a group signs * repeatedly and can have more than one session open at a time -- unlike a * ceremony, where "the room's ritual" identifies it. + * + * The id is required. It used to be optional, resolved to "the room's live + * session" for callers that did not know which one they meant -- a transcript + * line, mostly, since chat rows carried no session. Two proposals open at once + * made that guess wrong, and every one of those lines now names its session; a + * line too old to have one goes to [ProposalListRoute] instead, where the + * reader picks from the whole list rather than being sent to a guess. */ @Serializable data class FrostSigningRoute( val activeUserPublicKey: String, val chatRoomId: String, - - /** - * Null when the caller does not know which session it means. - * - * A transcript line is the main case: chat rows carry no session, and adding - * a column for one feature to a table every message uses is a poor trade for - * a lookup the screen can do. It resolves to the room's live session, which - * is the one a line is talking about in every case but a group running two - * at once. - */ - val sessionId: String? = null + val sessionId: String ): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 4ba70a91..e40b70a2 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -237,7 +237,15 @@ class ChatMessageListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable - fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) { + fun RenderMessages( + onOpenSharedKey: () -> Unit, + /** + * Opens the signing this line is about, by session id -- or the room's + * whole list of proposals when the line predates [ChatMessage.frostSigningSessionId] + * and cannot say which one it meant. + */ + onOpenSigning: (sessionId: String?) -> Unit, + ) { Column( modifier = Modifier.fillMaxWidth(), @@ -383,7 +391,11 @@ class ChatMessageListViewModel( localChatMessage = localChatMessage, isAnswered = localChatMessage.chatMessage.id in answeredRequests, isSettled = localChatMessage.chatMessage.id in settledRequests, - onClick = onOpenSigning + onClick = { + onOpenSigning( + localChatMessage.chatMessage.frostSigningSessionId + ) + } ) return@items } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index 6a1d0afa..3dc63459 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -25,7 +25,7 @@ import press.mantra.compose.ui.view.state.FrostSigningUIState class FrostSigningViewModel( val chatRoomId: String, - val sessionId: String?, + val sessionId: String, val activeUserPublicKey: HexKey, initialFrostSigningUIState: FrostSigningUIState, val chatRepository: ChatRepository, @@ -53,22 +53,12 @@ class FrostSigningViewModel( return@launch } - // A transcript line knows its room but not its session, so resolve one - // before watching anything. - val id = sessionId - ?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id - if (id == null) { - frostSigningUIState = - FrostSigningUIState.Error("This group is not signing anything right now.") - return@launch - } - frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom) combine( - frostSigningRepository.observeSessionById(id), - frostSigningRepository.observeMessages(id), - frostSigningRepository.observeItems(id) + frostSigningRepository.observeSessionById(sessionId), + frostSigningRepository.observeMessages(sessionId), + frostSigningRepository.observeItems(sessionId) ) { session, messages, items -> Triple(session, messages, items) } .collect { (session, messages, items) -> frostSigningUIState = FrostSigningUIState.Loaded( @@ -138,7 +128,7 @@ class FrostSigningViewModel( fun factory( chatRoomId: String, - sessionId: String?, + sessionId: String, activeUserPublicKey: HexKey, initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index ab051935..a9c2e838 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -426,6 +426,16 @@ what a line older than the column has to be read by, and is right for those rooms: nothing that predates batch proposals ran two sessions at once. A ceremony line still uses the clock always, since a room runs one ritual at a time. +The transcript answers one question about a proposal — is this still asking +something of you — and answers it in the middle of everything else the room said. +Two open proposals need a place that shows both, so `ProposalListScreen` lists a +room's sessions newest first with the ones waiting on the reader gathered at the +top, and the room's history under them. `FrostSigningRoute.sessionId` stopped +being optional at the same time: it existed for transcript lines that could not +say which session they meant, resolved to "the room's live session", and that +guess is exactly what two proposals make wrong. A line too old to name its +session opens the list instead. + --- ## Phase 6 — the test that actually proves it