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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user