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<>"{}|\\^`\[\]]+""")
@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,6 +636,22 @@ class MainViewModel(
val mxcUrl = c.source.url()
val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
val info = c.info
// 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(
@@ -600,6 +662,7 @@ class MainViewModel(
height = info?.height?.toInt(),
)
}
}
is MessageType.File -> {
val c = msgType.content
MessageContent.File(
@@ -762,7 +825,9 @@ class MainViewModel(
}
}
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.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,7 +270,8 @@ fun MessageTimeline(
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
} else {
CompactMessage(message.content)
CompactMessage(message)
}
}
}
}
@@ -311,7 +329,12 @@ 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
Column {
if (reply != null) {
ReplyConnector(reply, hasAvatar = true)
}
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
if (message.senderAvatarUrl != null) {
AsyncImage(
@@ -345,13 +368,109 @@ private fun FullMessage(message: MessageItem) {
MessageContentView(message.content)
}
}
}
}
@Composable
private fun CompactMessage(content: MessageContent) {
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(content)
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,
)
}
}
}