refactor: take the chat transcript out of the view model it was living in

Phase 6, step 7, and the prerequisite for the pane work rather than a tidy-up:
the transcript has to render at 400dp as a whole screen and at 900dp as the
detail half of a two-pane layout, and a layout that lives in a view model cannot
be composed twice or previewed once.

`ChatMessageListViewModel` was 1,113 lines, of which 380 were
`RenderMessages` -- a `@Composable` member function holding a `LazyColumn`, a
`DropdownMenu`, `Card`s and both of the app's only two `BoxWithConstraints` --
plus three private composables under the class. It is now 356 lines of state and
coroutines, and `ui/composable/widgets/chat/ChatTranscript.kt` is 779 lines of
layout.

**The move is verbatim.** `ProposalsAwaitingYouNotice`, `PrivateMessageNotice`
and `RitualNotice` are byte-identical -- `diff` says so. `RenderMessages` becomes
`ChatTranscript` and differs by exactly the signature line and fourteen
references that had been resolving against the enclosing class and now say
`viewModel.`. Nothing was rewritten while it was in the air; the diff is small
enough to read line by line, which is the only reason to move 760 lines in one
commit.

**Why a parameter and not a receiver.** Keeping it as
`fun ChatMessageListViewModel.ChatTranscript(...)` would have made the diff a
single word, and left every one of those fourteen reads bare. `openMessageActionsFor`
read bare says nothing about where it is kept; `viewModel.openMessageActionsFor`
says it survives the composition, which is the fact a reader of a transcript
needs and the one a pane split will make load-bearing.

**Two imports the extraction nearly lost.** `androidx.compose.runtime.getValue`
and `setValue` are used implicitly, by `by mutableStateOf`, so a "drop imports
whose name does not appear" pass drops both and the five delegated properties
stop compiling. The compiler caught it; noting it because the same pass over the
next file will do the same thing. The earlier version of that pass also required
an import's name not to follow a dot, which silently dropped every
`Modifier.fillMaxWidth()`-shaped extension.

`:composeApp:compileDebugKotlinAndroid`, `:composeApp:compileKotlinJvm` and the
jvm test suite all green. The three `Icons.Filled` deprecation warnings in the
new file came with the code and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 07:21:07 +02:00
parent 03d1e8e3b1
commit b5bb0042b1
3 changed files with 782 additions and 758 deletions

View File

@@ -56,6 +56,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ChatRoomMessagingViewModel
import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.widgets.chat.ChatTranscript
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.composable.widgets.Decorative
import mantra.composeapp.generated.resources.Res
@@ -155,7 +156,8 @@ fun ChatRoomMessagingScreen(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
key(true) {
chatMessageListViewModel.RenderMessages(
ChatTranscript(
viewModel = chatMessageListViewModel,
onOpenSharedKey = {
onNavigateToRoute.invoke(
DkgRitualRoute(

View File

@@ -0,0 +1,779 @@
package press.mantra.compose.ui.composable.widgets.chat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.CallMerge
import androidx.compose.material.icons.filled.FactCheck
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.WorkspacePremium
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Pending
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
import press.mantra.compose.extensions.shortened
import press.mantra.compose.extensions.toFormattedTimeAndDateString
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
import press.mantra.compose.ui.view.model.ChatMessageListViewModel
import press.mantra.compose.ui.view.state.ChatMessageListUIState
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.composable.widgets.Decorative
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared
import mantra.composeapp.generated.resources.loading
import mantra.composeapp.generated.resources.no_chat_message_relays_were_found_for_this
import mantra.composeapp.generated.resources.private_to_you
import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.waiting_for_your_signature
import mantra.composeapp.generated.resources.you
import mantra.composeapp.generated.resources.private_to
import mantra.composeapp.generated.resources.proposals_are_waiting_for_your_signature
import mantra.composeapp.generated.resources.reply_privately_to
import mantra.composeapp.generated.resources.sent_a_private_message_to
import press.mantra.compose.ui.composable.widgets.ErrorState
/**
* A chat room's transcript: its messages, the notices between them, and the standing ask
* at its foot.
*
* This was `ChatMessageListViewModel.RenderMessages`, 380 lines of layout inside a 1,100
* line view model, which is where the only two `BoxWithConstraints` in the app ended up.
* It moves here for the pane work: a transcript that has to render at 400dp as a whole
* screen and at 900dp as the detail half of a two-pane layout is a layout decision, and
* layout decisions that live in a view model cannot be composed twice or previewed once.
*
* The view model comes in as the first parameter rather than as a receiver. It is a
* parameter so the reader can see, at every use, which state is the room's and which is
* this composable's -- `openMessageActionsFor` read bare inside a class body says nothing
* about where it is kept, and there are fourteen such reads in here.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatTranscript(
viewModel: ChatMessageListViewModel,
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,
/** Opens the room's proposals, all of them, whatever their state. */
onOpenProposals: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (val chatRoomDetailMessageListUIState = viewModel.chatMessageListUIState) {
ChatMessageListUIState.Error -> {
ErrorState()
}
is ChatMessageListUIState.Loaded -> {
Spacer(
modifier = Modifier.weight(1f)
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (chatRoomDetailMessageListUIState.chatMessageList.isEmpty()) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space250),
text = stringResource(Res.string.currently_no_messages_have_been_shared),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
} else {
// Which request lines are still asking something of the
// reader. Read off the transcript rather than the session
// -- the rows are what a line rendered days later has --
// and both rules live on ChatMessage, where they can be
// stated once and tested.
val messages = chatRoomDetailMessageListUIState
.chatMessageList
.map { it.chatMessage }
val answeredRequests = ChatMessage.answeredRequests(messages)
val settledRequests = ChatMessage.settledRequests(messages)
// Read out here rather than inside the list, so the
// notice appearing and disappearing is a recomposition
// of this function and not of a lazy item that may not
// be composed at the time.
val awaitingYou = viewModel.proposalsAwaitingYou
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
reverseLayout = true
) {
// First item, and the layout is reversed, so this
// sits under the newest message and above the
// composer -- where the reader already is.
if (awaitingYou.isNotEmpty()) {
item {
ProposalsAwaitingYouNotice(
proposals = awaitingYou,
onClick = onOpenProposals
)
}
}
item {
if (viewModel.isReceiverChatMessageRelayListMissing.value) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Card(
onClick = {
// TODO: Open description for chatMessageRelayList
},
modifier = Modifier.fillMaxWidth(0.79f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(Res.string.no_chat_message_relays_were_found_for_this),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall
)
}
Icon(
Icons.Default.Info,
contentDescription = "Warning description"
)
}
}
}
}
}
items(
items = chatRoomDetailMessageListUIState.chatMessageList,
key = { it.chatMessage.id }
) { localChatMessage ->
// Not somebody's words -- see ChatMessage.DKG_TYPES.
// A bubble would attribute "a shared key ceremony
// started" to the coordinator as if they had said it.
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = onOpenSharedKey
)
return@items
}
// A private message this device cannot open. Everything
// about it is known except the one thing that matters,
// so it is a notice rather than an empty bubble --
// which would read as the sender having said nothing.
if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE &&
localChatMessage.chatMessage.content.isBlank()
) {
PrivateMessageNotice(
sender = viewModel.nameFor(localChatMessage.chatMessage.senderPublicKey),
recipient = viewModel.nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)
)
return@items
}
// Catching a member up is nobody's words either,
// and it leads nowhere: the work it delivered is
// in the artifact list, not behind this line.
// Passed as answered and settled because those
// are about requests and this asks nothing --
// which is what keeps it in the quiet tint.
if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = true,
isSettled = true,
onClick = {}
)
return@items
}
// Inviting somebody is nobody's words either, and
// for a while it was not in the room at all: the
// line was written when the Welcome went out, which
// on the deferred path is a relay round trip away
// and may never happen. Passed as answered and
// settled for the same reason the chronicle's are
// -- these report rather than ask, so they stay in
// the quiet tint and offer nothing to review.
if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = true,
isSettled = true,
onClick = {}
)
return@items
}
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
// reader needs from one is the event being signed
// rather than the state of the key.
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = {
onOpenSigning(
localChatMessage.chatMessage.frostSigningSessionId
)
}
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val screenWidth = maxWidth
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (localChatMessage.chatMessage.isUserMessage) {
Arrangement.End
} else {
Arrangement.Start
}
) {
// Only somebody else's message, and only in a
// group -- a NIP-17 room has no audience for a
// message to be private from.
val canReplyPrivately =
viewModel.localChatRoom.chatRoom.mlsGroupState != null &&
!localChatMessage.chatMessage.isUserMessage &&
viewModel.participantFor(localChatMessage.chatMessage.senderPublicKey) != null
Card(
modifier = Modifier.widthIn(
max = screenWidth * 0.8f
).wrapContentWidth(),
onClick = {
if (canReplyPrivately) {
viewModel.openMessageActions(localChatMessage.chatMessage.id)
}
},
) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
horizontalAlignment = if (localChatMessage.chatMessage.isUserMessage) {
Alignment.End
} else {
Alignment.Start
},
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space75)
) {
if (localChatMessage.chatMessage.isUserMessage.not()) {
Text(
text = localChatMessage.profile?.humanReadableNameOrPubkey() ?: localChatMessage.chatMessage.senderPublicKey,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
color = ProfileColor.fromPublicKey(localChatMessage.chatMessage.senderPublicKey),
style = MaterialTheme.typography.labelSmall
)
}
// A readable direct message must never
// pass for a public one. The label says
// who the other party is, since that is
// the thing a reader would otherwise
// assume was the whole room.
if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = if (localChatMessage.chatMessage.isUserMessage) {
stringResource(Res.string.private_to, viewModel.nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey))
} else {
stringResource(Res.string.private_to_you)
},
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmall
)
}
}
SelectionContainer {
Text(
text = localChatMessage.chatMessage.content,
style = MaterialTheme.typography.bodyMedium
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(
MaterialTheme.spacing.space125,
Alignment.End
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = localChatMessage.chatMessage.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
if (localChatMessage.chatMessage.isUserMessage) {
if (localChatMessage.chatMessageBroadcastNostrEventReceiptRelation != null) {
Icon(
Icons.Default.Check,
contentDescription = "Message sent"
)
} else if (localChatMessage.chatMessageBroadcastNostrEventRequestRelation != null) {
Icon(
Icons.Default.AccessTime,
contentDescription = "Message sent"
)
} else if (localChatMessage.chatMessageNostrEventRelation != null) {
Icon(
Icons.Default.Pending,
contentDescription = "Message signed and sealed status"
)
} else {
Icon(
Icons.Default.KeyOff,
contentDescription = "Unsealed message status"
)
}
}
}
}
}
DropdownMenu(
expanded = viewModel.openMessageActionsFor == localChatMessage.chatMessage.id,
onDismissRequest = { viewModel.openMessageActions(null) }
) {
DropdownMenuItem(
text = {
Text(stringResource(Res.string.reply_privately_to, viewModel.nameFor(localChatMessage.chatMessage.senderPublicKey)))
},
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = Decorative)
},
onClick = {
viewModel.participantFor(localChatMessage.chatMessage.senderPublicKey)
?.let { viewModel.startDirectMessage(it) }
}
)
}
}
}
}
}
}
}
}
ChatMessageListUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.loading),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
}
}
}
}
}
/**
* What the group is waiting on this member to sign, standing at the foot of the
* transcript.
*
* A proposal announces itself as a line and then the conversation carries it
* upward, but the decision it asks for does not expire with the scroll -- and a
* member who has not answered is what the whole room is waiting on. So the ask
* is restated where the reader already is, under the newest message, and is gone
* the moment nothing is owed. Nothing to dismiss: there is no state here beyond
* whether the group still needs an answer.
*
* Named after what it signs when there is one of them, because "a proposal is
* waiting" is not something anybody can decide about. With several, the count is
* the honest summary -- naming one of several here would say the others were not
* there.
*
* It opens the room's proposals rather than the one it names, in every case. The
* transcript's own lines are the way to one proposal; this is the standing count
* of what is owed, and the queue is the screen that answers the question it
* raises -- including for the one it could not name.
*/
@Composable
private fun ProposalsAwaitingYouNotice(
proposals: List<ChatMessageListViewModel.AwaitingProposal>,
onClick: () -> Unit,
) {
val single = proposals.singleOrNull()
val summary = single?.lead
?.let { lead ->
listOfNotNull(lead.label, lead.detail.takeIf { it.isNotBlank() })
.joinToString(" · ")
}
// Two ways for a proposal to have no lead, 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. Said the
// same way the proposal list says it.
?: single?.let {
if (it.eventCount == 0) {
"Nothing has arrived to sign yet"
} else {
"None of its events could be read"
}
}
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space150),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Draw,
contentDescription = Decorative
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
) {
Text(
text = if (single != null) {
stringResource(Res.string.waiting_for_your_signature)
} else {
stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size)
},
style = MaterialTheme.typography.labelMedium
)
if (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// The same word the transcript's own request lines use for the same
// thing, so a member reading down the room is not asked twice in two
// vocabularies.
Text(
text = stringResource(Res.string.review),
style = MaterialTheme.typography.labelLarge
)
Icon(
Icons.Default.ChevronRight,
contentDescription = "Open the group's proposals"
)
}
}
}
/**
* A private message this device cannot read, as a system line.
*
* Deliberately not a bubble. The group is meant to know that a private message was sent
* and to whom -- that is the honest half of the feature -- but an empty bubble attributed
* to the sender would read as them having said nothing, and a bubble with placeholder text
* would read as them having said the placeholder.
*
* Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md.
*/
@Composable
private fun PrivateMessageNotice(
sender: String,
recipient: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(Res.string.sent_a_private_message_to, sender, recipient),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall
)
}
}
/**
* A ChillDKG milestone, as a system line across the transcript.
*
* Deliberately not a bubble: nobody said this, and giving it a sender and a side
* would make the coordinator appear to have announced it. It is tappable because
* the point of telling the group is to give them somewhere to go — a ritual only
* finishes once every member's device has taken part, and the ladder that shows
* who it is waiting on lives on the shared-key screen.
*/
@Composable
private fun RitualNotice(
localChatMessage: LocalChatMessage,
isAnswered: Boolean,
isSettled: Boolean,
onClick: () -> Unit,
) {
val chatMessage = localChatMessage.chatMessage
// One per stage. A ceremony puts a dozen-odd lines in a row into the transcript,
// and with a single icon on all of them the reader has to actually read each to
// tell "somebody joined" from "somebody contributed" from "you are being asked
// for something". A request shares its stage's icon rather than getting a
// distinct one: it is the same step, before rather than after, and the primary
// tint and the Review affordance already say which.
val icon = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key
ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge
ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History
ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download
// An invite made and an invite sent are two separate steps on the deferred
// path, so they get separate icons -- the whole reason both lines exist is
// to be able to see that the first happened and the second did not.
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
else -> Icons.Default.PanTool
}
// The requests are the ritual lines that ask rather than report, and the ones
// the ceremony cannot get past on its own. Everything else here is deliberately
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
// So is a settled one -- declined, or signed by a quorum that did not need this
// member. Nothing was answered there, so it gets no tick, but offering to
// review it would be offering a decision that has already gone by.
val isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered && !isSettled
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier
.fillMaxWidth()
.minimumInteractiveComponentSize()
.clickable(onClick = onClick)
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = Decorative,
tint = tint
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
) {
Text(
text = buildAnnotatedString {
// Somebody opened this ceremony, or somebody walked away from it,
// and which member that was is the point of the line. Resolved
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {
append(
if (chatMessage.isUserMessage) {
stringResource(Res.string.you)
} else {
localChatMessage.profile?.humanReadableNameOrPubkey()
?: chatMessage.senderPublicKey.shortened()
}
)
}
append(" ")
}
append(chatMessage.content)
},
style = MaterialTheme.typography.bodySmall,
color = tint
)
Text(
text = chatMessage.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isRequest) {
Text(
text = stringResource(Res.string.review),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
} else if (isAnswered) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "You approved this",
tint = MaterialTheme.colorScheme.primary
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Open the shared key ceremony",
tint = tint
)
}
}

View File

@@ -1,79 +1,23 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.CallMerge
import androidx.compose.material.icons.filled.FactCheck
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.WorkspacePremium
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Pending
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.SynchronizationFilter
import press.mantra.compose.extensions.shortened
import press.mantra.compose.extensions.toFormattedTimeAndDateString
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.nostr.MemberProfileSync
import press.mantra.compose.nostr.Nip17Filters
@@ -82,7 +26,6 @@ import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.text.ProposedEvent
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
import press.mantra.compose.ui.view.state.ChatMessageListUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -92,23 +35,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.composable.widgets.Decorative
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared
import mantra.composeapp.generated.resources.loading
import mantra.composeapp.generated.resources.no_chat_message_relays_were_found_for_this
import mantra.composeapp.generated.resources.private_to_you
import mantra.composeapp.generated.resources.review
import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.waiting_for_your_signature
import mantra.composeapp.generated.resources.you
import mantra.composeapp.generated.resources.private_to
import mantra.composeapp.generated.resources.proposals_are_waiting_for_your_signature
import mantra.composeapp.generated.resources.reply_privately_to
import mantra.composeapp.generated.resources.sent_a_private_message_to
import press.mantra.compose.ui.composable.widgets.ErrorState
class ChatMessageListViewModel(
initialChatMessageListUIState: ChatMessageListUIState,
@@ -386,388 +312,6 @@ class ChatMessageListViewModel(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
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,
/** Opens the room's proposals, all of them, whatever their state. */
onOpenProposals: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (val chatRoomDetailMessageListUIState = this@ChatMessageListViewModel.chatMessageListUIState) {
ChatMessageListUIState.Error -> {
ErrorState()
}
is ChatMessageListUIState.Loaded -> {
Spacer(
modifier = Modifier.weight(1f)
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (chatRoomDetailMessageListUIState.chatMessageList.isEmpty()) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
Text(
modifier = Modifier.padding(MaterialTheme.spacing.space250),
text = stringResource(Res.string.currently_no_messages_have_been_shared),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space250)
)
} else {
// Which request lines are still asking something of the
// reader. Read off the transcript rather than the session
// -- the rows are what a line rendered days later has --
// and both rules live on ChatMessage, where they can be
// stated once and tested.
val messages = chatRoomDetailMessageListUIState
.chatMessageList
.map { it.chatMessage }
val answeredRequests = ChatMessage.answeredRequests(messages)
val settledRequests = ChatMessage.settledRequests(messages)
// Read out here rather than inside the list, so the
// notice appearing and disappearing is a recomposition
// of this function and not of a lazy item that may not
// be composed at the time.
val awaitingYou = proposalsAwaitingYou
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
reverseLayout = true
) {
// First item, and the layout is reversed, so this
// sits under the newest message and above the
// composer -- where the reader already is.
if (awaitingYou.isNotEmpty()) {
item {
ProposalsAwaitingYouNotice(
proposals = awaitingYou,
onClick = onOpenProposals
)
}
}
item {
if (isReceiverChatMessageRelayListMissing.value) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Card(
onClick = {
// TODO: Open description for chatMessageRelayList
},
modifier = Modifier.fillMaxWidth(0.79f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(Res.string.no_chat_message_relays_were_found_for_this),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall
)
}
Icon(
Icons.Default.Info,
contentDescription = "Warning description"
)
}
}
}
}
}
items(
items = chatRoomDetailMessageListUIState.chatMessageList,
key = { it.chatMessage.id }
) { localChatMessage ->
// Not somebody's words -- see ChatMessage.DKG_TYPES.
// A bubble would attribute "a shared key ceremony
// started" to the coordinator as if they had said it.
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = onOpenSharedKey
)
return@items
}
// A private message this device cannot open. Everything
// about it is known except the one thing that matters,
// so it is a notice rather than an empty bubble --
// which would read as the sender having said nothing.
if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE &&
localChatMessage.chatMessage.content.isBlank()
) {
PrivateMessageNotice(
sender = nameFor(localChatMessage.chatMessage.senderPublicKey),
recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)
)
return@items
}
// Catching a member up is nobody's words either,
// and it leads nowhere: the work it delivered is
// in the artifact list, not behind this line.
// Passed as answered and settled because those
// are about requests and this asks nothing --
// which is what keeps it in the quiet tint.
if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = true,
isSettled = true,
onClick = {}
)
return@items
}
// Inviting somebody is nobody's words either, and
// for a while it was not in the room at all: the
// line was written when the Welcome went out, which
// on the deferred path is a relay round trip away
// and may never happen. Passed as answered and
// settled for the same reason the chronicle's are
// -- these report rather than ask, so they stay in
// the quiet tint and offer nothing to review.
if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = true,
isSettled = true,
onClick = {}
)
return@items
}
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
// reader needs from one is the event being signed
// rather than the state of the key.
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = {
onOpenSigning(
localChatMessage.chatMessage.frostSigningSessionId
)
}
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val screenWidth = maxWidth
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (localChatMessage.chatMessage.isUserMessage) {
Arrangement.End
} else {
Arrangement.Start
}
) {
// Only somebody else's message, and only in a
// group -- a NIP-17 room has no audience for a
// message to be private from.
val canReplyPrivately =
localChatRoom.chatRoom.mlsGroupState != null &&
!localChatMessage.chatMessage.isUserMessage &&
participantFor(localChatMessage.chatMessage.senderPublicKey) != null
Card(
modifier = Modifier.widthIn(
max = screenWidth * 0.8f
).wrapContentWidth(),
onClick = {
if (canReplyPrivately) {
openMessageActions(localChatMessage.chatMessage.id)
}
},
) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
horizontalAlignment = if (localChatMessage.chatMessage.isUserMessage) {
Alignment.End
} else {
Alignment.Start
},
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space75)
) {
if (localChatMessage.chatMessage.isUserMessage.not()) {
Text(
text = localChatMessage.profile?.humanReadableNameOrPubkey() ?: localChatMessage.chatMessage.senderPublicKey,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
color = ProfileColor.fromPublicKey(localChatMessage.chatMessage.senderPublicKey),
style = MaterialTheme.typography.labelSmall
)
}
// A readable direct message must never
// pass for a public one. The label says
// who the other party is, since that is
// the thing a reader would otherwise
// assume was the whole room.
if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = if (localChatMessage.chatMessage.isUserMessage) {
stringResource(Res.string.private_to, nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey))
} else {
stringResource(Res.string.private_to_you)
},
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmall
)
}
}
SelectionContainer {
Text(
text = localChatMessage.chatMessage.content,
style = MaterialTheme.typography.bodyMedium
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(
MaterialTheme.spacing.space125,
Alignment.End
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = localChatMessage.chatMessage.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall
)
if (localChatMessage.chatMessage.isUserMessage) {
if (localChatMessage.chatMessageBroadcastNostrEventReceiptRelation != null) {
Icon(
Icons.Default.Check,
contentDescription = "Message sent"
)
} else if (localChatMessage.chatMessageBroadcastNostrEventRequestRelation != null) {
Icon(
Icons.Default.AccessTime,
contentDescription = "Message sent"
)
} else if (localChatMessage.chatMessageNostrEventRelation != null) {
Icon(
Icons.Default.Pending,
contentDescription = "Message signed and sealed status"
)
} else {
Icon(
Icons.Default.KeyOff,
contentDescription = "Unsealed message status"
)
}
}
}
}
}
DropdownMenu(
expanded = openMessageActionsFor == localChatMessage.chatMessage.id,
onDismissRequest = { openMessageActions(null) }
) {
DropdownMenuItem(
text = {
Text(stringResource(Res.string.reply_privately_to, nameFor(localChatMessage.chatMessage.senderPublicKey)))
},
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = Decorative)
},
onClick = {
participantFor(localChatMessage.chatMessage.senderPublicKey)
?.let { startDirectMessage(it) }
}
)
}
}
}
}
}
}
}
}
ChatMessageListUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.loading),
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
}
}
}
}
}
fun sendMessage(textFieldState: TextFieldState) {
if (textFieldState.text.isNotBlank()) {
@@ -810,304 +354,3 @@ class ChatMessageListViewModel(
}
}
}
/**
* What the group is waiting on this member to sign, standing at the foot of the
* transcript.
*
* A proposal announces itself as a line and then the conversation carries it
* upward, but the decision it asks for does not expire with the scroll -- and a
* member who has not answered is what the whole room is waiting on. So the ask
* is restated where the reader already is, under the newest message, and is gone
* the moment nothing is owed. Nothing to dismiss: there is no state here beyond
* whether the group still needs an answer.
*
* Named after what it signs when there is one of them, because "a proposal is
* waiting" is not something anybody can decide about. With several, the count is
* the honest summary -- naming one of several here would say the others were not
* there.
*
* It opens the room's proposals rather than the one it names, in every case. The
* transcript's own lines are the way to one proposal; this is the standing count
* of what is owed, and the queue is the screen that answers the question it
* raises -- including for the one it could not name.
*/
@Composable
private fun ProposalsAwaitingYouNotice(
proposals: List<ChatMessageListViewModel.AwaitingProposal>,
onClick: () -> Unit,
) {
val single = proposals.singleOrNull()
val summary = single?.lead
?.let { lead ->
listOfNotNull(lead.label, lead.detail.takeIf { it.isNotBlank() })
.joinToString(" · ")
}
// Two ways for a proposal to have no lead, 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. Said the
// same way the proposal list says it.
?: single?.let {
if (it.eventCount == 0) {
"Nothing has arrived to sign yet"
} else {
"None of its events could be read"
}
}
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space150),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Draw,
contentDescription = Decorative
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
) {
Text(
text = if (single != null) {
stringResource(Res.string.waiting_for_your_signature)
} else {
stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size)
},
style = MaterialTheme.typography.labelMedium
)
if (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// The same word the transcript's own request lines use for the same
// thing, so a member reading down the room is not asked twice in two
// vocabularies.
Text(
text = stringResource(Res.string.review),
style = MaterialTheme.typography.labelLarge
)
Icon(
Icons.Default.ChevronRight,
contentDescription = "Open the group's proposals"
)
}
}
}
/**
* A private message this device cannot read, as a system line.
*
* Deliberately not a bubble. The group is meant to know that a private message was sent
* and to whom -- that is the honest half of the feature -- but an empty bubble attributed
* to the sender would read as them having said nothing, and a bubble with placeholder text
* would read as them having said the placeholder.
*
* Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md.
*/
@Composable
private fun PrivateMessageNotice(
sender: String,
recipient: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Lock,
contentDescription = Decorative,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(Res.string.sent_a_private_message_to, sender, recipient),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall
)
}
}
/**
* A ChillDKG milestone, as a system line across the transcript.
*
* Deliberately not a bubble: nobody said this, and giving it a sender and a side
* would make the coordinator appear to have announced it. It is tappable because
* the point of telling the group is to give them somewhere to go — a ritual only
* finishes once every member's device has taken part, and the ladder that shows
* who it is waiting on lives on the shared-key screen.
*/
@Composable
private fun RitualNotice(
localChatMessage: LocalChatMessage,
isAnswered: Boolean,
isSettled: Boolean,
onClick: () -> Unit,
) {
val chatMessage = localChatMessage.chatMessage
// One per stage. A ceremony puts a dozen-odd lines in a row into the transcript,
// and with a single icon on all of them the reader has to actually read each to
// tell "somebody joined" from "somebody contributed" from "you are being asked
// for something". A request shares its stage's icon rather than getting a
// distinct one: it is the same step, before rather than after, and the primary
// tint and the Review affordance already say which.
val icon = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key
ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge
ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History
ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download
// An invite made and an invite sent are two separate steps on the deferred
// path, so they get separate icons -- the whole reason both lines exist is
// to be able to see that the first happened and the second did not.
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
else -> Icons.Default.PanTool
}
// The requests are the ritual lines that ask rather than report, and the ones
// the ceremony cannot get past on its own. Everything else here is deliberately
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
// So is a settled one -- declined, or signed by a quorum that did not need this
// member. Nothing was answered there, so it gets no tick, but offering to
// review it would be offering a decision that has already gone by.
val isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered && !isSettled
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier
.fillMaxWidth()
.minimumInteractiveComponentSize()
.clickable(onClick = onClick)
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = Decorative,
tint = tint
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)
) {
Text(
text = buildAnnotatedString {
// Somebody opened this ceremony, or somebody walked away from it,
// and which member that was is the point of the line. Resolved
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {
append(
if (chatMessage.isUserMessage) {
stringResource(Res.string.you)
} else {
localChatMessage.profile?.humanReadableNameOrPubkey()
?: chatMessage.senderPublicKey.shortened()
}
)
}
append(" ")
}
append(chatMessage.content)
},
style = MaterialTheme.typography.bodySmall,
color = tint
)
Text(
text = chatMessage.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isRequest) {
Text(
text = stringResource(Res.string.review),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
} else if (isAnswered) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "You approved this",
tint = MaterialTheme.colorScheme.primary
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Open the shared key ceremony",
tint = tint
)
}
}