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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:43:00 +02:00
parent 116cc96de4
commit dbdff55ee0
8 changed files with 57 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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