diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index bc6ba0d0..55215ce9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.model.FrostSignerMessage import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @Dao interface FrostSigningSessionDao { @@ -20,11 +21,17 @@ interface FrostSigningSessionDao { fun observeSessionById(sessionId: String): Flow /** - * A room's signing sessions, newest first. Unlike a DKG a group signs - * repeatedly, so there is no single "current" one to observe. + * A room's signing sessions, newest first, each with the events it signs and + * the member who proposed it. + * + * Unlike a DKG a group signs repeatedly, so there is no single "current" one + * to observe -- and no single one to show, which is what the proposal list + * exists for. See [LocalFrostSigningSession] for why the items travel with + * the session rather than being fetched per row. */ + @Transaction @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") - fun observeSessionsForChatRoom(chatRoomId: String): Flow> + fun observeSessionsForChatRoom(chatRoomId: String): Flow> @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession? diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalFrostSigningSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalFrostSigningSession.kt new file mode 100644 index 00000000..13d2c0e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalFrostSigningSession.kt @@ -0,0 +1,45 @@ +package press.mantra.compose.database.model.intermdiate + +import androidx.room3.Embedded +import androidx.room3.Relation +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Profile + +/** + * One signing session with everything a list of them has to show. + * + * A proposal is not readable from its session row alone: the row says how the + * signing is going, and the items say what is being signed. A screen listing a + * room's proposals needs both of every one of them, and asking per session would + * be one query per row. + * + * The proposer is joined for the same reason the transcript joins a sender -- + * so a rename follows, and so a member who has only ever been seen as a pubkey + * is not stuck on the placeholder a profile is created with. + */ +data class LocalFrostSigningSession( + @Embedded val session: FrostSigningSession, + + /** + * The events the session signs. + * + * Room does not order a relation, so this comes back in whatever order the + * query returns. Sorting is [items]'s job: item order is protocol, not + * presentation -- see [FrostSigningItem.itemIndex]. + */ + @Relation( + parentColumns = ["id"], + entityColumns = ["sessionId"] + ) + val unorderedItems: List = emptyList(), + + @Relation( + parentColumns = ["coordinatorPublicKey"], + entityColumns = ["publicKey"] + ) + val proposer: Profile? = null, +) { + /** The batch in the order its proposal fixed. */ + val items: List get() = unorderedItems.sortedBy { it.itemIndex } +} 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 8737dba9..4a0c514c 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 @@ -12,6 +12,7 @@ import press.mantra.compose.database.model.FrostSignerMessage 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 @@ -25,7 +26,7 @@ class DatabaseFrostSigningRepository( override fun observeSessionById(sessionId: String): Flow = database.frostSigningSessionDao().observeSessionById(sessionId) - override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) override fun observeMessages(sessionId: String): Flow> = 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 5b9d5edd..7d13801c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.database.model.FrostSignerMessage 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 /** * Reads, opens and answers FROST signing sessions. @@ -21,8 +22,13 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom interface FrostSigningRepository { fun observeSessionById(sessionId: String): Flow - /** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */ - fun observeSessionsForChatRoom(chatRoomId: String): Flow> + /** + * Every session the room has run, newest first, each with what it signs. + * + * Unlike a ceremony, signing recurs -- and two sessions can be open at once, + * which is why the proposal list shows all of them rather than the latest. + */ + fun observeSessionsForChatRoom(chatRoomId: String): Flow> fun observeMessages(sessionId: String): Flow> @@ -137,7 +143,7 @@ interface FrostSigningRepository { val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository { override fun observeSessionById(sessionId: String): Flow = flowOf(null) - override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = flowOf(emptyList()) override fun observeMessages(sessionId: String): Flow> = diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index bc19ed8e..8cb0ed7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Schema @@ -56,6 +57,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar @@ -365,6 +367,34 @@ fun ChatRoomDetailScreen( Text("Shared Key") } } + + // Next to the key, because that is what they sign with. + // The transcript carries a proposal past as it happens; + // this is where a member goes to find one that has + // scrolled away, or to see what the group has signed. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Proposals" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Proposals") + } + } } item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt new file mode 100644 index 00000000..5a2ee9a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -0,0 +1,416 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.model.ProposalListViewModel +import press.mantra.compose.ui.view.state.ProposalListUIState + +/** + * Everything the group has asked its shared key to sign. + * + * The transcript shows a proposal as it happens, in among everything else that + * was said, and answers one question about it: is this still asking something of + * you. That is enough for a room signing one thing at a time and not enough for + * a room signing two -- a chapter and its translation go out as two sessions, + * and the transcript has no way to show that the second is still open once the + * first is done. + * + * So: one row per proposal, each carrying its own state, oldest kept rather than + * dropped. The ones still waiting on the reader come first, because those are + * the only ones with anything to do in them; the rest are history, and history + * is the other half of what this screen is for. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProposalListScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialProposalListUIState: ProposalListUIState = ProposalListUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val proposalListViewModel: ProposalListViewModel = viewModel( + factory = ProposalListViewModel.factory( + chatRoomId = chatRoomId, + initialProposalListUIState = initialProposalListUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + LaunchedEffect(true) { + if (initialProposalListUIState == ProposalListUIState.Loading) { + proposalListViewModel.initiate() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Proposals", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + when (val state = proposalListViewModel.proposalListUIState) { + is ProposalListUIState.Loading -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + CircularProgressIndicator() + } + + is ProposalListUIState.Error -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(50.dp)) + Text(text = state.message, textAlign = TextAlign.Center) + } + + is ProposalListUIState.Loaded -> { + val open: (ProposalListUIState.Proposal) -> Unit = { proposal -> + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = proposal.session.id + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (state.proposals.isEmpty()) { + item { + Text( + modifier = Modifier.fillMaxWidth().padding(top = 50.dp), + text = "This group has not been asked to sign anything yet.", + textAlign = TextAlign.Center + ) + } + } + + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = "Waiting for you", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + items( + items = state.waitingForYou, + key = { it.session.id } + ) { proposal -> + ProposalCard(proposal = proposal, onClick = { open(proposal) }) + } + } + + if (state.rest.isNotEmpty()) { + // Headed only when there is a section above to tell it + // apart from; on its own it is the whole screen, which + // the title already says. + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = "Everything else", + style = MaterialTheme.typography.labelMedium + ) + } + } + + items( + items = state.rest, + key = { it.session.id } + ) { proposal -> + ProposalCard(proposal = proposal, onClick = { open(proposal) }) + } + } + } + } + } + } +} + +/** + * One proposal, named after the first thing it signs. + * + * A batch is named after its first item because that is the one the rest hang + * off -- a chapter's chunks carry the chapter's id, so the chapter is what the + * proposal is about and the chunks are how big it is. The count says the rest. + */ +@Composable +private fun ProposalCard( + proposal: ProposalListUIState.Proposal, + onClick: () -> Unit, +) { + val lead = proposal.signs.firstOrNull() + + // A row is named after its lead, and has to say something when there is no + // lead to name it after. Two ways for that to happen and they are different + // situations: a session can exist before its proposal has arrived, and a + // proposal can arrive holding events this build cannot read. + val label = lead?.label ?: "Proposal" + val detail = lead?.detail?.takeIf { it.isNotBlank() } + ?: if (proposal.eventCount == 0) { + "Nothing has arrived to sign yet" + } else { + "None of its events could be read" + } + + Card( + onClick = onClick, + colors = if (proposal.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } + ) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + Icon( + imageVector = when { + proposal.awaitsYou -> Icons.Default.Draw + proposal.session.stage == FrostSigningStage.COMPLETE -> + Icons.Default.CheckCircle + proposal.session.stage == FrostSigningStage.FAILED -> + Icons.Default.ErrorOutline + else -> Icons.Default.HourglassEmpty + }, + contentDescription = null, + tint = when { + proposal.session.stage == FrostSigningStage.FAILED -> + MaterialTheme.colorScheme.error + proposal.awaitsYou -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + // The same word the transcript uses for the same thing, so a + // member who came here looking for the line they tapped finds + // it saying what it said there. + if (proposal.awaitsYou) { + Text( + text = "Review", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + + Icon( + Icons.Default.ChevronRight, + contentDescription = "Open this proposal" + ) + } + }, + overlineContent = { + Text(text = label) + }, + headlineContent = { + Text( + text = detail, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (proposal.eventCount > 1) { + Text( + text = "with ${proposal.eventCount - 1} more " + + (if (proposal.eventCount == 2) "event" else "events") + + ", signed together" + ) + } + + // A batch is all-or-nothing, so an item nobody can read is a + // reason to refuse the whole proposal. It is said on the row + // rather than left for the screen behind it. + if (proposal.unreadable > 0) { + Text( + text = "${proposal.unreadable} of them could not be read", + color = MaterialTheme.colorScheme.error + ) + } + + Text(text = statusOf(proposal)) + + Text( + text = "${proposal.proposerName} · " + + proposal.session.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** + * Where a proposal has got to, in a line. + * + * Shorter than the signing screen's version of the same question, and it has to + * be: this is one row of a list somebody is scanning, not the page they are + * deciding on. What it must never do is call a session finished when it is not, + * or waiting when the decision has gone. + */ +private fun statusOf(proposal: ProposalListUIState.Proposal): String { + val session = proposal.session + + if (proposal.awaitsYou) return "Needs your review" + + return when (session.stage) { + FrostSigningStage.COLLECTING_NONCES -> + "You agreed. Waiting for ${session.threshold} of " + + "${session.participantCount} members to take part." + + FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES -> + if (session.isSigner()) { + "You are signing. Waiting on the rest of the signers." + } else { + "Enough members took part without you. Waiting on them to sign." + } + + FrostSigningStage.COMPLETE -> "Signed by the group." + + // The reason is the whole content of a failure -- "declined on this + // device" and "the group could not agree" are different things to + // have happened -- so it stands in for the word "abandoned" whenever + // there is one. + FrostSigningStage.FAILED -> + session.failureReason ?: "Abandoned. Nothing was signed." + } +} + +@Preview +@Composable +private fun ProposalListScreenPreview() { + val session = FrostSigningSession( + id = "sessionId", + chatRoomId = "chatRoomId", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "dkgSessionId", + threshold = 2, + participantCount = 3, + signerId = 1 + ) + + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProposalListScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = "chatRoomId", + initialProposalListUIState = ProposalListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "u".repeat(64), + subject = "Group (#admins)", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ) + ), + proposals = listOf( + ProposalListUIState.Proposal( + session = session, + proposerName = "Ada", + signs = listOf( + ProposedEvent.Summary("New chapter", "Genesis 1 · 797 words · 31 chunks") + ), + awaitsYou = true + ), + ProposalListUIState.Proposal( + session = session.copy( + id = "olderSessionId", + stage = FrostSigningStage.COMPLETE + ), + proposerName = "You", + signs = listOf( + ProposedEvent.Summary("New translation", "isiZulu · public · CC BY-SA") + ) + ) + ) + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {}, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index e0ada82a..6a847d32 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -110,6 +110,7 @@ import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute 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.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -121,6 +122,7 @@ import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen import press.mantra.compose.ui.composable.AddDialectScreen import press.mantra.compose.ui.composable.FrostSigningScreen +import press.mantra.compose.ui.composable.ProposalListScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen @@ -890,6 +892,22 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProposalListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { signingRoute -> + navController.navigate(route = signingRoute) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposalListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposalListRoute.kt new file mode 100644 index 00000000..f2e913b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposalListRoute.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Everything a group has asked its shared key to sign. + * + * A room's own list, not the reader's: a proposal is put to the whole group, and + * which of them are waiting on this member is a status on each row rather than a + * different list. See [FrostSigningRoute] for one of them. + */ +@Serializable +data class ProposalListRoute( + val activeUserPublicKey: String, + val chatRoomId: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt new file mode 100644 index 00000000..938d3d83 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.ui.view.model + +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.Event +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.extensions.shortened +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.view.state.ProposalListUIState + +/** + * Everything a group has asked its shared key to sign. + * + * A room signs repeatedly, and can have two sessions open at once -- proposing a + * chapter and the translation that depends on it is two. Watching the whole list + * rather than the latest session is the point: which of them is waiting on the + * reader is a fact about each one, and there is no ordering of them in which the + * second is not worth showing. + */ +class ProposalListViewModel( + val chatRoomId: String, + initialProposalListUIState: ProposalListUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var proposalListUIState: ProposalListUIState by mutableStateOf(initialProposalListUIState) + private set + + private val logger = Logger.withTag(TAG) + + /** + * Loads the room, then watches its proposals for as long as the screen lives. + * + * Every one of them moves on messages arriving from other members, so a + * screen that read the list once would go stale the moment somebody else + * signed -- which is exactly when a member is likely to be looking at it. + */ + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + proposalListUIState = ProposalListUIState.Error("Couldn't find the group") + return@launch + } + + frostSigningRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions -> + logger.d("${sessions.size} proposals in $chatRoomId") + + proposalListUIState = ProposalListUIState.Loaded( + localChatRoom = localChatRoom, + proposals = sessions.map { asProposal(it) } + ) + } + } + } + + /** + * One session as a row. + * + * Whether it is waiting on the reader is asked of the manager rather than + * worked out here, so the list and the signing screen cannot disagree about + * which proposals still have a decision in them. + */ + private fun asProposal(local: LocalFrostSigningSession): ProposalListUIState.Proposal { + val items = local.items + val events = items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } + + return ProposalListUIState.Proposal( + session = local.session, + proposerName = when { + local.session.isCoordinator() -> "You" + else -> local.proposer?.humanReadableNameOrPubkey() + ?: local.session.coordinatorPublicKey.shortened() + }, + signs = events.map { ProposedEvent.summarize(it) }, + unreadable = items.size - events.size, + awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items) + ) + } + + companion object { + private const val TAG = "ProposalListViewModel" + + fun factory( + chatRoomId: String, + initialProposalListUIState: ProposalListUIState = ProposalListUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProposalListViewModel( + chatRoomId = chatRoomId, + initialProposalListUIState = initialProposalListUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposalListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposalListUIState.kt new file mode 100644 index 00000000..80ce274a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposalListUIState.kt @@ -0,0 +1,69 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.text.ProposedEvent + +sealed interface ProposalListUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Every proposal the room has made, newest first. */ + val proposals: List = emptyList(), + ): ProposalListUIState { + /** + * The ones that cannot move until this member answers them. + * + * Split out rather than sorted to the top, because they are a different + * kind of thing to read: the rest of the list is what the group has done, + * these are what it is waiting on the reader for. A group can have several + * at once -- proposing a chapter and its translation is two -- and burying + * the second one under the first one's history is the whole reason this + * screen exists. + */ + val waitingForYou: List = proposals.filter { it.awaitsYou } + + val rest: List = proposals.filterNot { it.awaitsYou } + } + + /** + * One proposal, as a row. + * + * Everything here is derived once per emission rather than at render: the + * events are parsed out of stored JSON, which is not work to repeat on every + * recomposition of a scrolling list. + */ + data class Proposal( + val session: FrostSigningSession, + + /** Whoever put it to the group, as a name to read. */ + val proposerName: String, + + /** + * What it signs, in the order the batch fixed -- so the first is the one + * the others hang off, and is what the row is named after. + */ + val signs: List = emptyList(), + + /** + * How many of its events could not be read back. + * + * Shown rather than hidden. A batch is all-or-nothing, so an unreadable + * item is a reason not to sign any of it, and a row that quietly listed + * only the readable ones would be understating what is being asked. + */ + val unreadable: Int = 0, + + /** Whether the session is still waiting on this device's owner. */ + val awaitsYou: Boolean = false, + ) { + /** The batch's size, readable or not. */ + val eventCount: Int get() = signs.size + unreadable + } + + data class Error( + val message: String + ): ProposalListUIState + + data object Loading: ProposalListUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt index d2c8a669..33599999 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.dao import androidx.room3.Room import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase @@ -166,6 +167,32 @@ class FrostSigningSessionDaoJvmTest { assertEquals("third", db.frostSigningSessionDao().getLatestSessionForChatRoom(roomOne)?.id) } + /** + * What the proposal list reads. A room can have two sessions open at once, so the list has + * to carry every one of them with what it signs -- the events are what names a proposal, + * and a session row on its own says only how the signing is going. + */ + @Test + fun `a rooms proposals arrive with their events and their proposer`() = runBlocking { + seedRooms() + session("first", createdAt = Instant.fromEpochSeconds(1_000), events = 3) + session("second", createdAt = Instant.fromEpochSeconds(2_000), events = 1) + + val proposals = db.frostSigningSessionDao().observeSessionsForChatRoom(roomOne).first() + + assertEquals(listOf("second", "first"), proposals.map { it.session.id }) + + // Room does not order a relation, so the batch order is the model's doing. + // It is protocol rather than presentation: two devices reading a batch in + // different orders aggregate against different messages. + val first = assertNotNull(proposals.firstOrNull { it.session.id == "first" }) + assertEquals(listOf(0, 1, 2), first.items.map { it.itemIndex }) + + // Named, not keyed. A proposal is somebody asking the group for something, + // and a row that cannot say who is asking is worse than an ugly one. + assertEquals("user", first.proposer?.userName) + } + /** * Signing happens in the #admins room, and a device can be in more than one. A session from * another room appearing here would have a signer answering a request its group never made.