From f2b3899f9f0a52c0f01835b8f9757ff660d8d88a Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Tue, 24 Feb 2026 22:38:04 +0000 Subject: [PATCH] fixing file send --- .../ui/screens/main/MainViewModel.kt | 89 +++++++-- .../main/components/MessageTimeline.kt | 189 ++++++++++++++---- 2 files changed, 231 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index 904c18f..caae61a 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -61,6 +61,13 @@ sealed interface MessageContent { private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""") +@Immutable +data class ReplyInfo( + val eventId: String, + val senderName: String, + val body: String, +) + @Immutable data class MessageItem( val eventId: String, @@ -69,6 +76,7 @@ data class MessageItem( val senderAvatarUrl: String? = null, val content: MessageContent, val timestamp: Long, + val replyTo: ReplyInfo? = null, ) data class ChannelSection( @@ -462,9 +470,9 @@ class MainViewModel( } if (_selectedChannel.value == roomId) { _messages.value = ArrayList(cached) - // Clamp unread marker to valid range — hide if it would be at/beyond the end + // Clamp unread marker — only hide if beyond valid range val marker = _unreadMarkerIndex.value - if (marker >= 0 && marker >= cached.size - 1) { + if (marker >= 0 && marker >= cached.size) { _unreadMarkerIndex.value = -1 } sendReadReceipt(roomId) @@ -494,10 +502,47 @@ class MainViewModel( if (eventId in ids) return false val content = eventItem.content + var replyInfo: ReplyInfo? = null val msgContent: MessageContent = when (content) { is TimelineItemContent.MsgLike -> { when (val kind = content.content.kind) { is MsgLikeKind.Message -> { + // Extract reply info from MsgLikeContent.inReplyTo + try { + val inReplyTo = content.content.inReplyTo + if (inReplyTo != null) { + val replyEventId = inReplyTo.eventId() + val event = inReplyTo.event() + if (event is org.matrix.rustcomponents.sdk.EmbeddedEventDetails.Ready) { + val replySenderProfile = event.senderProfile + val replySender = if (replySenderProfile is ProfileDetails.Ready && replySenderProfile.displayName != null) { + replySenderProfile.displayName!! + } else { + event.sender.removePrefix("@").substringBefore(":") + } + val replyBody = when (val rc = event.content) { + is TimelineItemContent.MsgLike -> { + when (val rk = rc.content.kind) { + is MsgLikeKind.Message -> rk.content.body + else -> "" + } + } + else -> "" + } + replyInfo = ReplyInfo( + eventId = replyEventId, + senderName = replySender, + body = replyBody, + ) + } else { + replyInfo = ReplyInfo( + eventId = replyEventId, + senderName = "", + body = "Message not available", + ) + } + } + } catch (_: Exception) { } resolveMessageType(kind.content.msgType, kind.content.body) ?: return false } @@ -537,6 +582,7 @@ class MainViewModel( senderAvatarUrl = senderAvatar, content = msgContent, timestamp = eventItem.timestamp.toLong(), + replyTo = replyInfo, ) ids.add(eventId) @@ -590,15 +636,32 @@ class MainViewModel( val mxcUrl = c.source.url() val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val info = c.info - val thumbMxc = info?.thumbnailSource?.url() - val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url - MessageContent.Video( - body = c.filename, - url = url, - thumbnailUrl = thumbnailUrl, - width = info?.width?.toInt(), - height = info?.height?.toInt(), - ) + // Detect Discord bridge GIFs: m.video with tenor/giphy body URL, + // or short mp4 with no duration and no thumbnail (fi.mau.gif pattern) + val isGifVideo = body.contains("tenor.com/") || + body.contains("giphy.com/") || + (info?.mimetype == "video/mp4" && + info.duration == null && + info.thumbnailSource == null && + (info.size?.toLong() ?: Long.MAX_VALUE) < 10_000_000) + if (isGifVideo) { + MessageContent.Gif( + body = c.filename, + url = url, + width = info?.width?.toInt(), + height = info?.height?.toInt(), + ) + } else { + val thumbMxc = info?.thumbnailSource?.url() + val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url + MessageContent.Video( + body = c.filename, + url = url, + thumbnailUrl = thumbnailUrl, + width = info?.width?.toInt(), + height = info?.height?.toInt(), + ) + } } is MessageType.File -> { val c = msgType.content @@ -762,7 +825,9 @@ class MainViewModel( } } tempFile.delete() - } catch (_: Exception) { } + } catch (e: Exception) { + android.util.Log.e("SendFile", "Failed to send file", e) + } } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 2bcfdf5..9b764d8 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -40,12 +41,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke 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 android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures import com.mikepenz.markdown.m3.Markdown @@ -78,12 +85,14 @@ import org.koin.compose.koinInject import coil3.compose.AsyncImage import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageItem +import com.example.fluffytrix.ui.screens.main.ReplyInfo import java.text.SimpleDateFormat import java.util.Date import java.util.Locale private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} } private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} } +private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} } private val senderColors = arrayOf( Color(0xFF5865F2), @@ -194,6 +203,14 @@ fun MessageTimeline( } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + CompositionLocalProvider( + LocalScrollToEvent provides { eventId -> + val idx = messages.indexOfFirst { it.eventId == eventId } + if (idx >= 0) { + scope.launch { listState.animateScrollToItem(idx) } + } + }, + ) { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, @@ -245,7 +262,7 @@ fun MessageTimeline( Spacer(modifier = Modifier.height(12.dp)) FullMessage(message) } else { - CompactMessage(message.content) + CompactMessage(message) } } } else { @@ -253,11 +270,12 @@ fun MessageTimeline( Spacer(modifier = Modifier.height(12.dp)) FullMessage(message) } else { - CompactMessage(message.content) + CompactMessage(message) } } } } + } // Jump to bottom button if (!isAtBottom) { @@ -311,47 +329,148 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) { private fun FullMessage(message: MessageItem) { val senderColor = remember(message.senderName) { colorForSender(message.senderName) } val time = remember(message.timestamp) { formatTimestamp(message.timestamp) } + val reply = message.replyTo - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - if (message.senderAvatarUrl != null) { - AsyncImage( - model = message.senderAvatarUrl, - contentDescription = null, - modifier = Modifier.size(40.dp).clip(CircleShape), - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)), - contentAlignment = Alignment.Center, - ) { - Text( - message.senderName.take(1).uppercase(), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = senderColor, - ) - } + Column { + if (reply != null) { + ReplyConnector(reply, hasAvatar = true) } - - Spacer(Modifier.width(12.dp)) - - Column { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor) - Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + if (message.senderAvatarUrl != null) { + AsyncImage( + model = message.senderAvatarUrl, + contentDescription = null, + modifier = Modifier.size(40.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + Text( + message.senderName.take(1).uppercase(), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = senderColor, + ) + } + } + + Spacer(Modifier.width(12.dp)) + + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor) + Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(2.dp)) + MessageContentView(message.content) } - Spacer(Modifier.height(2.dp)) - MessageContentView(message.content) } } } @Composable -private fun CompactMessage(content: MessageContent) { - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { - Spacer(Modifier.width(52.dp)) - MessageContentView(content) +private fun CompactMessage(message: MessageItem) { + Column { + if (message.replyTo != null) { + ReplyConnector(message.replyTo, hasAvatar = false) + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { + Spacer(Modifier.width(52.dp)) + MessageContentView(message.content) + } + } +} + +/** + * Discord-style reply preview with an L-shaped connector line. + * The connector curves from the avatar column up to the reply text. + * + * Layout: + * ``` + * ╭── @sender reply text... + * │ + * [avatar] ... + * ``` + */ +@Composable +private fun ReplyConnector(reply: ReplyInfo, hasAvatar: Boolean) { + val scrollToEvent = LocalScrollToEvent.current + val replySenderColor = remember(reply.senderName) { colorForSender(reply.senderName) } + val connectorColor = MaterialTheme.colorScheme.outlineVariant + + // The connector + reply text row + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { scrollToEvent(reply.eventId) } + .padding(top = 4.dp), + ) { + // L-shaped connector drawn on Canvas + // Avatar center is at x=20dp. Reply text starts at x=52dp. + // The connector goes: vertical from bottom up, then curves right to the reply row. + val connectorStroke = 2.dp + Canvas( + modifier = Modifier + .width(52.dp) + .height(28.dp) + .align(Alignment.BottomStart), + ) { + val strokeW = connectorStroke.toPx() + val avatarCenterX = 20.dp.toPx() + val curveRadius = 8.dp.toPx() + val midY = size.height / 2f + + val path = Path().apply { + // Start from bottom (will connect to avatar below) + moveTo(avatarCenterX, size.height) + // Vertical line up to where the curve starts + lineTo(avatarCenterX, midY + curveRadius) + // Curve from vertical to horizontal + quadraticTo( + avatarCenterX, midY, + avatarCenterX + curveRadius, midY, + ) + // Horizontal line to the right edge + lineTo(size.width, midY) + } + drawPath( + path = path, + color = connectorColor, + style = Stroke(width = strokeW, cap = StrokeCap.Round), + ) + } + + // Reply text content — positioned to the right of connector + Row( + modifier = Modifier + .padding(start = 56.dp) + .align(Alignment.CenterEnd) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (reply.senderName.isNotEmpty()) { + Text( + text = reply.senderName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = replySenderColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + ) + } + Text( + text = reply.body.replace('\n', ' '), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } }