better emoji reactions

This commit is contained in:
2026-03-02 22:21:23 +00:00
parent 2169f28632
commit 2b554dc227
6 changed files with 205 additions and 126 deletions

View File

@@ -101,6 +101,9 @@ dependencies {
implementation(libs.markdown.renderer.code)
implementation(libs.markdown.renderer.coil3)
// Jetpack Emoji Picker
implementation(libs.emoji.picker)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -88,6 +88,7 @@ data class MessageItem(
val timestamp: Long,
val replyTo: ReplyInfo? = null,
val threadRootEventId: String? = null,
val reactions: Map<String, List<String>> = emptyMap(), // emoji -> list of full Matrix user IDs
)
data class ThreadItem(
@@ -752,6 +753,12 @@ class MainViewModel(
senderNameCache[localpart] = senderName
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar
val reactions = if (content is TimelineItemContent.MsgLike) {
content.content.reactions
.filter { it.senders.isNotEmpty() }
.associate { reaction -> reaction.key to reaction.senders.map { it.senderId } }
} else emptyMap()
val msg = MessageItem(
eventId = eventId,
senderId = localpart,
@@ -761,6 +768,7 @@ class MainViewModel(
timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo,
threadRootEventId = threadRootId,
reactions = reactions,
)
ids.add(eventId)

View File

@@ -3,6 +3,7 @@ package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -22,13 +23,15 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -50,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -67,6 +71,8 @@ import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.emojipicker.EmojiViewItem
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -78,6 +84,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@@ -103,6 +110,8 @@ import java.util.Locale
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
private val LocalCurrentUserId = compositionLocalOf<String?> { null }
private val senderColors = arrayOf(
Color(0xFF5865F2),
@@ -204,9 +213,18 @@ fun MessageTimeline(
)
}
val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
{ eventId, emoji ->
if (selectedThread != null) onSendThreadReaction(eventId, emoji)
else onSendReaction(eventId, emoji)
}
}
CompositionLocalProvider(
LocalImageViewer provides { url -> fullscreenImageUrl = url },
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
LocalReactionHandler provides reactionHandler,
LocalCurrentUserId provides currentUserId,
) {
Column(
modifier = modifier
@@ -447,6 +465,42 @@ private fun ThreadTopBar(title: String, onClose: () -> Unit) {
}
}
@Composable
private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
if (reactions.isEmpty()) return
val onReact = LocalReactionHandler.current
val currentUserId = LocalCurrentUserId.current
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 4.dp),
) {
reactions.forEach { (emoji, senders) ->
val isMine = currentUserId != null && senders.any { it == currentUserId }
Surface(
shape = RoundedCornerShape(12.dp),
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clickable { onReact(eventId, emoji) },
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(emoji, fontSize = 14.sp)
Text(
"${senders.size}",
style = MaterialTheme.typography.labelSmall,
color = if (isMine) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0, onLongPress: (MessageItem) -> Unit = {}) {
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
@@ -488,6 +542,7 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
}
Spacer(Modifier.height(2.dp))
MessageContentView(message.content)
ReactionRow(message.eventId, message.reactions)
if (threadReplyCount > 0) {
Text(
text = "$threadReplyCount ${if (threadReplyCount == 1) "reply" else "replies"}",
@@ -511,7 +566,10 @@ private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> U
}
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Spacer(Modifier.width(52.dp))
MessageContentView(message.content)
Column {
MessageContentView(message.content)
ReactionRow(message.eventId, message.reactions)
}
}
}
}
@@ -1028,29 +1086,100 @@ private fun MessageContextMenu(
onEdit: () -> Unit,
onStartThread: () -> Unit,
) {
var showEmojiPicker by remember { mutableStateOf(false) }
val currentOnReact by rememberUpdatedState(onReact)
if (showEmojiPicker) {
Dialog(
onDismissRequest = { showEmojiPicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = { showEmojiPicker = false }) {
Icon(
Icons.Default.Close,
"Close picker",
tint = MaterialTheme.colorScheme.onSurface,
)
}
Text(
"Choose an emoji",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(8.dp))
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
currentOnReact(emojiViewItem.emoji)
showEmojiPicker = false
}
},
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
}
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = null,
text = {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
// Quick emoji reactions
LazyRow(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(QUICK_REACTIONS) { emoji ->
QUICK_REACTIONS.forEach { emoji ->
Box(
modifier = Modifier
.size(40.dp)
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onReact(emoji) },
contentAlignment = Alignment.Center,
) {
Text(emoji, fontSize = 20.sp)
Text(emoji, fontSize = 18.sp)
}
}
}
Button(
onClick = { showEmojiPicker = true },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(
Icons.Default.Add,
"Add reaction",
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("More emojis")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
TextButton(
onClick = onReply,