fixing file send
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user