Place transparent outline reaction heart below Android message bubbles
This commit is contained in:
1 parent
f8d0acb38d
commit
aa58c7fd3d
3 files changed
+77
-10
No files matched your search
+8
-4
@@ -87,10 +87,14 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp)
|
||||
Box(Modifier.weight(1f)) {
|
||||
Column {
|
||||
if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName)
|
||||
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
|
||||
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
|
||||
MessageReactions(message, reactionCatalog, onReact)
|
||||
val footer: @Composable () -> Unit = {
|
||||
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
|
||||
MessageReactions(message, reactionCatalog, onReact)
|
||||
}
|
||||
if (target is ConversationTarget.Channel && message.contentType == "poll")
|
||||
PollMessageBubble(message, onVote, authorName, footer)
|
||||
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile,
|
||||
onOpenFile, onOpenImage, onPlayVideo, onLink, footer)
|
||||
}
|
||||
}
|
||||
if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
|
||||
|
||||
+19
-1
@@ -42,6 +42,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -100,6 +101,20 @@ private fun detectEmojiOnly(text: String): Int {
|
||||
private fun deliveryLabel(message: Message): String =
|
||||
if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes)
|
||||
|
||||
/* Footer получает ширину пузыря после измерения содержимого, включая медиа. */
|
||||
@Composable
|
||||
internal fun MessageWithFooter(footer: @Composable () -> Unit, content: @Composable () -> Unit) {
|
||||
Layout(content = { Box { content() }; Box { footer() } }) { children, constraints ->
|
||||
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val body = children[0].measure(childConstraints)
|
||||
val bottom = children[1].measure(childConstraints.copy(minWidth = body.width, maxWidth = body.width))
|
||||
layout(body.width, body.height + bottom.height) {
|
||||
body.placeRelative(0, 0)
|
||||
bottom.placeRelative(0, body.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
|
||||
@@ -109,7 +124,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
onOpenFile: (Message) -> Unit = {},
|
||||
onOpenImage: (Message) -> Unit = {},
|
||||
onPlayVideo: (Message) -> Unit = {},
|
||||
onLink: (String) -> Unit = {}) {
|
||||
onLink: (String) -> Unit = {},
|
||||
footer: @Composable () -> Unit = {}) {
|
||||
val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble
|
||||
val textColor = ChatColors.Text
|
||||
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
||||
@@ -141,6 +157,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
MessageWithFooter(footer) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
|
||||
@@ -196,6 +213,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -8,7 +9,15 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.*
|
||||
@@ -45,10 +54,14 @@ fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, Boo
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
|
||||
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String,
|
||||
footer: @Composable () -> Unit = {}) {
|
||||
var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) }
|
||||
val poll = message.feedback?.poll
|
||||
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start) {
|
||||
MessageWithFooter(footer) {
|
||||
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -70,6 +83,8 @@ fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
|
||||
}
|
||||
|
||||
@@ -123,12 +138,20 @@ private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> U
|
||||
@Composable
|
||||
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
|
||||
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) }
|
||||
FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(onClick = {
|
||||
LogManager.addLog("DEBUG", "POLL", "reaction picker opened message=${message.messageId} type=${message.contentType}")
|
||||
picker = true
|
||||
}) {
|
||||
ReactionHeart(stringResource(R.string.poll_react))
|
||||
}
|
||||
for (reaction in message.feedback?.reactions.orEmpty()) {
|
||||
FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) },
|
||||
FilterChip(selected = reaction.mine, onClick = {
|
||||
LogManager.addLog("DEBUG", "POLL", "reaction toggled message=${message.messageId} emoji=${reaction.id} mine=${reaction.mine}")
|
||||
onReact(message, reaction.id)
|
||||
},
|
||||
label = { Text("${reaction.emoji} ${reaction.count}") })
|
||||
}
|
||||
TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) }
|
||||
}
|
||||
if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
|
||||
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
@@ -139,3 +162,25 @@ fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (M
|
||||
} },
|
||||
dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } })
|
||||
}
|
||||
|
||||
/* Два контура без заливки: более широкий тёмный даёт тень ровно в один физический пиксель. */
|
||||
@Composable
|
||||
private fun ReactionHeart(description: String) {
|
||||
val path = remember {
|
||||
Path().apply {
|
||||
moveTo(12f, 21f)
|
||||
cubicTo(10.5f, 19.5f, 3f, 14.2f, 3f, 8.5f)
|
||||
cubicTo(3f, 3f, 9f, 2f, 12f, 6f)
|
||||
cubicTo(15f, 2f, 21f, 3f, 21f, 8.5f)
|
||||
cubicTo(21f, 14.2f, 13.5f, 19.5f, 12f, 21f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.size(24.dp).semantics { contentDescription = description }) {
|
||||
val factor = size.width / 24f
|
||||
scale(factor, factor, pivot = Offset.Zero) {
|
||||
drawPath(path, Color.Black.copy(alpha = 0.6f), style = Stroke(width = 1.8f + 2f / factor, join = StrokeJoin.Round))
|
||||
drawPath(path, Color.White, style = Stroke(width = 1.8f, join = StrokeJoin.Round))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user