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