From b5bb0042b1b14df92461dbc8798f0a4cd21422e3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 07:21:07 +0200 Subject: [PATCH] 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 --- .../ui/composable/ChatRoomMessagingScreen.kt | 4 +- .../composable/widgets/chat/ChatTranscript.kt | 779 ++++++++++++++++++ .../ui/view/model/ChatMessageListViewModel.kt | 757 ----------------- 3 files changed, 782 insertions(+), 758 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 7606c2d6..45b62ff1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt new file mode 100644 index 00000000..263092f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -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, + 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 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 01000b42..3b036c8a 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -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, - 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 - ) - } -}