fixing file send

This commit is contained in:
2026-02-24 22:38:04 +00:00
parent 39010f882d
commit f2b3899f9f
2 changed files with 231 additions and 47 deletions

View File

@@ -61,6 +61,13 @@ sealed interface MessageContent {
private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""") private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
@Immutable
data class ReplyInfo(
val eventId: String,
val senderName: String,
val body: String,
)
@Immutable @Immutable
data class MessageItem( data class MessageItem(
val eventId: String, val eventId: String,
@@ -69,6 +76,7 @@ data class MessageItem(
val senderAvatarUrl: String? = null, val senderAvatarUrl: String? = null,
val content: MessageContent, val content: MessageContent,
val timestamp: Long, val timestamp: Long,
val replyTo: ReplyInfo? = null,
) )
data class ChannelSection( data class ChannelSection(
@@ -462,9 +470,9 @@ class MainViewModel(
} }
if (_selectedChannel.value == roomId) { if (_selectedChannel.value == roomId) {
_messages.value = ArrayList(cached) _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 val marker = _unreadMarkerIndex.value
if (marker >= 0 && marker >= cached.size - 1) { if (marker >= 0 && marker >= cached.size) {
_unreadMarkerIndex.value = -1 _unreadMarkerIndex.value = -1
} }
sendReadReceipt(roomId) sendReadReceipt(roomId)
@@ -494,10 +502,47 @@ class MainViewModel(
if (eventId in ids) return false if (eventId in ids) return false
val content = eventItem.content val content = eventItem.content
var replyInfo: ReplyInfo? = null
val msgContent: MessageContent = when (content) { val msgContent: MessageContent = when (content) {
is TimelineItemContent.MsgLike -> { is TimelineItemContent.MsgLike -> {
when (val kind = content.content.kind) { when (val kind = content.content.kind) {
is MsgLikeKind.Message -> { 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) resolveMessageType(kind.content.msgType, kind.content.body)
?: return false ?: return false
} }
@@ -537,6 +582,7 @@ class MainViewModel(
senderAvatarUrl = senderAvatar, senderAvatarUrl = senderAvatar,
content = msgContent, content = msgContent,
timestamp = eventItem.timestamp.toLong(), timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo,
) )
ids.add(eventId) ids.add(eventId)
@@ -590,15 +636,32 @@ class MainViewModel(
val mxcUrl = c.source.url() val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val info = c.info val info = c.info
val thumbMxc = info?.thumbnailSource?.url() // Detect Discord bridge GIFs: m.video with tenor/giphy body URL,
val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url // or short mp4 with no duration and no thumbnail (fi.mau.gif pattern)
MessageContent.Video( val isGifVideo = body.contains("tenor.com/") ||
body = c.filename, body.contains("giphy.com/") ||
url = url, (info?.mimetype == "video/mp4" &&
thumbnailUrl = thumbnailUrl, info.duration == null &&
width = info?.width?.toInt(), info.thumbnailSource == null &&
height = info?.height?.toInt(), (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 -> { is MessageType.File -> {
val c = msgType.content val c = msgType.content
@@ -762,7 +825,9 @@ class MainViewModel(
} }
} }
tempFile.delete() tempFile.delete()
} catch (_: Exception) { } } catch (e: Exception) {
android.util.Log.e("SendFile", "Failed to send file", e)
}
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
@@ -78,12 +85,14 @@ import org.koin.compose.koinInject
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageContent
import com.example.fluffytrix.ui.screens.main.MessageItem import com.example.fluffytrix.ui.screens.main.MessageItem
import com.example.fluffytrix.ui.screens.main.ReplyInfo
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} } private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} } private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
private val senderColors = arrayOf( private val senderColors = arrayOf(
Color(0xFF5865F2), Color(0xFF5865F2),
@@ -194,6 +203,14 @@ fun MessageTimeline(
} }
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
@@ -245,7 +262,7 @@ fun MessageTimeline(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
FullMessage(message) FullMessage(message)
} else { } else {
CompactMessage(message.content) CompactMessage(message)
} }
} }
} else { } else {
@@ -253,11 +270,12 @@ fun MessageTimeline(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
FullMessage(message) FullMessage(message)
} else { } else {
CompactMessage(message.content) CompactMessage(message)
} }
} }
} }
} }
}
// Jump to bottom button // Jump to bottom button
if (!isAtBottom) { if (!isAtBottom) {
@@ -311,47 +329,148 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
private fun FullMessage(message: MessageItem) { private fun FullMessage(message: MessageItem) {
val senderColor = remember(message.senderName) { colorForSender(message.senderName) } val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) } val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
val reply = message.replyTo
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Column {
if (message.senderAvatarUrl != null) { if (reply != null) {
AsyncImage( ReplyConnector(reply, hasAvatar = true)
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,
)
}
} }
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Spacer(Modifier.width(12.dp)) if (message.senderAvatarUrl != null) {
AsyncImage(
Column { model = message.senderAvatarUrl,
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { contentDescription = null,
Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor) modifier = Modifier.size(40.dp).clip(CircleShape),
Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) 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 @Composable
private fun CompactMessage(content: MessageContent) { private fun CompactMessage(message: MessageItem) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { Column {
Spacer(Modifier.width(52.dp)) if (message.replyTo != null) {
MessageContentView(content) 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,
)
}
} }
} }