feat: give a group's proposals a screen of their own
The transcript is where a proposal is met, and it is a bad place to keep one. It answers one question -- is this still asking something of you -- in the middle of everything else the room said that day, and then it scrolls. Until now the only other way in was a signing screen that resolved "the room's live session", so a room with two proposals open had one of them reachable and the group's history had none of it. So: one row per session, newest first, live. The ones still waiting on the reader are gathered under "Waiting for you" and the rest follow, because they are two different kinds of thing to read -- the rest is what the group has done, those are what it is waiting on this member for -- and because burying the second open proposal under the first one's history is the whole failure this screen exists to answer. **Each row carries its own state, from the same rule the signing screen uses.** Whether a proposal still has a decision in it is asked of `FrostSigningManager.isAwaitingApproval` rather than worked out again here, so the list and the screen behind it cannot come to different answers about the same session. The rest of the status is the session's own stage said briefly: you agreed and it is waiting on n of m, you are one of the signers, enough members took part without you, signed, or -- for an abandoned one -- its failure reason, because "declined on this device" and "the group could not agree" are different things to have happened and the reason is the whole content of the ending. **Named after the lead item.** A batch's first item is the one the rest hang off -- a chapter's chunks carry the chapter's id -- so the chapter names the row and the count says the rest of it. An item that could not be read is said on the row rather than left for the screen behind it: a batch is all-or-nothing, so an unreadable item is a reason to refuse the whole proposal. **One query, not one per row.** `LocalFrostSigningSession` embeds the session and relates its items and its proposer, and `observeSessionsForChatRoom` becomes a `@Transaction` query over it -- the repository already declared that method and nothing called it, so this is the shape it should have had rather than a second query beside it. The model sorts the items rather than the query: Room does not order a relation, and item order is protocol rather than presentation, since two devices reading a batch in different orders aggregate against different messages. The proposer is joined for the reason the transcript joins a sender -- so a rename follows, and a member seen only as a pubkey is not stuck on the placeholder their profile was created with. **Derived once per emission.** Every row's events come out of stored JSON. That is not work to repeat on each recomposition of a scrolling list, so the view model does it when the flow emits and the screen renders what it is handed. Reachable from the group's detail screen, beside Shared Key and gated the same way: a shared threshold key only means anything in a room where every member is an equal admin, and a room with no key to sign with has nothing to propose. **Tests.** FrostSigningSessionDaoJvmTest covers what the list reads -- two sessions in a room come back newest first, each with its items in `itemIndex` order despite the relation's own order, and with the proposer resolved to a name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<FrostSigningSession?>
|
||||
|
||||
/**
|
||||
* 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<List<FrostSigningSession>>
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<LocalFrostSigningSession>>
|
||||
|
||||
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
|
||||
|
||||
@@ -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<FrostSigningItem> = emptyList(),
|
||||
|
||||
@Relation(
|
||||
parentColumns = ["coordinatorPublicKey"],
|
||||
entityColumns = ["publicKey"]
|
||||
)
|
||||
val proposer: Profile? = null,
|
||||
) {
|
||||
/** The batch in the order its proposal fixed. */
|
||||
val items: List<FrostSigningItem> get() = unorderedItems.sortedBy { it.itemIndex }
|
||||
}
|
||||
@@ -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<FrostSigningSession?> =
|
||||
database.frostSigningSessionDao().observeSessionById(sessionId)
|
||||
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<LocalFrostSigningSession>> =
|
||||
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
|
||||
@@ -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<FrostSigningSession?>
|
||||
|
||||
/** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
|
||||
/**
|
||||
* 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<List<LocalFrostSigningSession>>
|
||||
|
||||
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
|
||||
|
||||
@@ -137,7 +143,7 @@ interface FrostSigningRepository {
|
||||
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
|
||||
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> = flowOf(null)
|
||||
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<LocalFrostSigningSession>> =
|
||||
flowOf(emptyList())
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ProposalListRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProposalListRoute>()
|
||||
|
||||
ProposalListScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToRoute = { signingRoute ->
|
||||
navController.navigate(route = signingRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ArtifactDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ArtifactDetailRoute>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Proposal> = 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<Proposal> = proposals.filter { it.awaitsYou }
|
||||
|
||||
val rest: List<Proposal> = 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<ProposedEvent.Summary> = 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user