Browse Source

Move Android reactions inside bubbles beside the timestamp

master
evgeny 6 days ago
parent
commit
1f4266f8b1
  1. 46
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  2. 21
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

46
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -101,16 +101,31 @@ 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 ->
internal fun MessageWithFooter(message: Message, footer: @Composable () -> Unit,
modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = {
Box { content() }
Box { footer() }
Text(listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp, color = ChatColors.Secondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}) { 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) {
val preferred = children[1].maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(constraints.maxWidth)
val gap = if (preferred > 0) 6.dp.roundToPx() else 0
val time = children[2].measure(childConstraints.copy(
maxWidth = (constraints.maxWidth - gap - if (preferred > 0) 32.dp.roundToPx() else 0).coerceAtLeast(0)))
val width = maxOf(constraints.minWidth, body.width, minOf(constraints.maxWidth, preferred + gap + time.width))
val reactions = children[1].measure(childConstraints.copy(maxWidth = (width - time.width - gap).coerceAtLeast(0)))
val rowHeight = maxOf(reactions.height, time.height)
val lastLineHeight = maxOf(time.height, minOf(reactions.height, 32.dp.roundToPx()))
val top = body.height + 4.dp.roundToPx()
layout(width, top + rowHeight) {
body.placeRelative(0, 0)
bottom.placeRelative(0, body.height)
reactions.placeRelative(0, top)
time.placeRelative(width - time.width, top + rowHeight - lastLineHeight + (lastLineHeight - time.height) / 2)
}
}
}
@ -157,8 +172,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
) {
MessageWithFooter(footer) {
Column(
MessageWithFooter(message, footer,
modifier = Modifier
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
// Тень рисуем до clip, чтобы выступающие справа и снизу края оставались видны.
@ -172,6 +186,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
.background(bgColor)
.padding(12.dp)
) {
Column {
if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author,
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
Spacer(Modifier.height(2.dp))
@ -203,15 +218,6 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
}
}
if (!isVoice) {
Spacer(Modifier.height(2.dp))
Text(
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
@ -314,12 +320,6 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
fontSize = 12.sp,
color = ChatColors.Secondary
)
Text(
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp)
)
}
}
}

21
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

@ -60,11 +60,11 @@ fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) ->
val poll = message.feedback?.poll
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),
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)) {
MessageWithFooter(message, footer, Modifier.padding(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium)
if (poll == null) Text(stringResource(R.string.poll_pending))
else {
@ -82,8 +82,8 @@ fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) ->
TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) }
}
}
}
}
}
}
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
}
@ -134,25 +134,28 @@ private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> U
} })
}
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) }
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = {
LogManager.addLog("DEBUG", "POLL", "reaction picker opened message=${message.messageId} type=${message.contentType}")
picker = true
}) {
}, modifier = Modifier.size(32.dp)) {
ReactionHeart(stringResource(R.string.poll_react))
}
for (reaction in message.feedback?.reactions.orEmpty()) {
FilterChip(selected = reaction.mine, onClick = {
FilterChip(selected = reaction.mine, modifier = Modifier.height(32.dp), 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}") })
}
}
}
if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (reaction in catalog) TextButton(onClick = { onReact(message, reaction.id); picker = false }) { Text(reaction.emoji) }
@ -176,7 +179,7 @@ private fun ReactionHeart(description: String) {
close()
}
}
Canvas(Modifier.size(24.dp).semantics { contentDescription = description }) {
Canvas(Modifier.size(20.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))

Loading…
Cancel
Save