This commit is contained in:
2026-02-25 12:23:31 +00:00
parent 1cd4486599
commit fd42bd65b0
4 changed files with 160 additions and 79 deletions

View File

@@ -38,3 +38,20 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
- Material You (Material 3 dynamic colors) theming - Material You (Material 3 dynamic colors) theming
- Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`) for Matrix protocol - Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`) for Matrix protocol
- Jetpack Compose for UI - Jetpack Compose for UI
## Matrix Rust SDK: Raw Event JSON
The SDK exposes full raw event JSON through the `LazyTimelineItemProvider` on each `EventTimelineItem`:
```kotlin
val rawJson: String? = try {
eventItem.lazyProvider.debugInfo().originalJson
} catch (_: Exception) { null }
```
`EventTimelineItemDebugInfo` fields:
- `originalJson` — full raw JSON of the event (what Element shows in "View Source")
- `latestEditJson` — raw JSON of the latest edit, if any
- `model` — internal SDK model debug string
This is useful for reading custom/unstandardized fields that the SDK doesn't expose directly (e.g. `fi.mau.gif`, `com.beeper.per_message_profile`, bridge metadata).

View File

@@ -95,7 +95,7 @@ fun MainScreen(
messages = messages, messages = messages,
onToggleMemberList = { viewModel.toggleMemberList() }, onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) }, onSendMessage = { viewModel.sendMessage(it) },
onSendFile = { viewModel.sendFile(it) }, onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
onLoadMore = { viewModel.loadMoreMessages() }, onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex, unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View File

@@ -140,6 +140,8 @@ class MainViewModel(
private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap()) private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap())
private val _roomUnreadCount = MutableStateFlow<Map<String, ULong>>(emptyMap()) private val _roomUnreadCount = MutableStateFlow<Map<String, ULong>>(emptyMap())
// Rooms we've marked as read but server may not have processed yet
private val _recentlyReadRooms = MutableStateFlow<Set<String>>(emptySet())
private val _unreadMarkerIndex = MutableStateFlow(-1) private val _unreadMarkerIndex = MutableStateFlow(-1)
val unreadMarkerIndex: StateFlow<Int> = _unreadMarkerIndex val unreadMarkerIndex: StateFlow<Int> = _unreadMarkerIndex
@@ -223,7 +225,7 @@ class MainViewModel(
val syncUnreadCount = mutableMapOf<String, ULong>() val syncUnreadCount = mutableMapOf<String, ULong>()
for (room in joinedRooms) { for (room in joinedRooms) {
val roomId = room.id() val roomId = room.id()
if (roomId == _selectedChannel.value) continue if (roomId == _selectedChannel.value || roomId in _recentlyReadRooms.value) continue
try { try {
val info = room.roomInfo() val info = room.roomInfo()
val highlight = info.highlightCount val highlight = info.highlightCount
@@ -543,7 +545,8 @@ class MainViewModel(
} }
} }
} catch (_: Exception) { } } catch (_: Exception) { }
resolveMessageType(kind.content.msgType, kind.content.body) val rawJson = try { eventItem.lazyProvider.debugInfo().originalJson } catch (_: Exception) { null }
resolveMessageType(kind.content.msgType, kind.content.body, rawJson)
?: return false ?: return false
} }
is MsgLikeKind.UnableToDecrypt -> { is MsgLikeKind.UnableToDecrypt -> {
@@ -594,7 +597,7 @@ class MainViewModel(
return true return true
} }
private fun resolveMessageType(msgType: MessageType, body: String): MessageContent? { private fun resolveMessageType(msgType: MessageType, body: String, rawJson: String? = null): MessageContent? {
return when (msgType) { return when (msgType) {
is MessageType.Text -> { is MessageType.Text -> {
val text = msgType.content.body val text = msgType.content.body
@@ -636,14 +639,10 @@ 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
// Detect Discord bridge GIFs: m.video with tenor/giphy body URL, // Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL
// or short mp4 with no duration and no thumbnail (fi.mau.gif pattern) val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) ||
val isGifVideo = body.contains("tenor.com/") || body.contains("tenor.com/") ||
body.contains("giphy.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) { if (isGifVideo) {
MessageContent.Gif( MessageContent.Gif(
body = c.filename, body = c.filename,
@@ -752,9 +751,10 @@ class MainViewModel(
} }
} }
fun sendFile(uri: Uri) { fun sendFiles(uris: List<Uri>, caption: String?) {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
for (uri in uris) {
try { try {
val contentResolver = application.contentResolver val contentResolver = application.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
@@ -766,13 +766,13 @@ class MainViewModel(
} ?: "file" } ?: "file"
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: return@launch ?: continue
android.util.Log.d("SendFile", "fileName=$fileName mimeType=$mimeType bytes=${bytes.size}") android.util.Log.d("SendFile", "fileName=$fileName mimeType=$mimeType bytes=${bytes.size}")
val params = UploadParameters( val params = UploadParameters(
source = UploadSource.Data(bytes, fileName), source = UploadSource.Data(bytes, fileName),
caption = null, caption = caption,
formattedCaption = null, formattedCaption = null,
mentions = null, mentions = null,
inReplyTo = null, inReplyTo = null,
@@ -793,6 +793,7 @@ class MainViewModel(
} }
} }
} }
}
fun selectHome() { fun selectHome() {
if (_selectedSpace.value == null) { if (_selectedSpace.value == null) {
@@ -1050,12 +1051,18 @@ class MainViewModel(
private fun sendReadReceipt(roomId: String) { private fun sendReadReceipt(roomId: String) {
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
// Suppress unread status while the receipt is in flight
_recentlyReadRooms.value = _recentlyReadRooms.value + roomId
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val room = client.getRoom(roomId) ?: return@launch val room = client.getRoom(roomId) ?: return@launch
room.markAsRead(org.matrix.rustcomponents.sdk.ReceiptType.READ) room.markAsRead(org.matrix.rustcomponents.sdk.ReceiptType.READ)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to send read receipt for $roomId", e) android.util.Log.e("MainVM", "Failed to send read receipt for $roomId", e)
} finally {
// Allow the server a couple seconds to propagate before re-checking
kotlinx.coroutines.delay(3000)
_recentlyReadRooms.value = _recentlyReadRooms.value - roomId
} }
} }
} }

View File

@@ -126,7 +126,7 @@ fun MessageTimeline(
messages: List<MessageItem>, messages: List<MessageItem>,
onToggleMemberList: () -> Unit, onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onSendFile: (Uri) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit,
onLoadMore: () -> Unit = {}, onLoadMore: () -> Unit = {},
unreadMarkerIndex: Int = -1, unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -229,7 +229,7 @@ fun MessageTimeline(
) { index -> ) { index ->
val message = messages[index] val message = messages[index]
val next = if (index + 1 < count) messages[index + 1] else null val next = if (index + 1 < count) messages[index + 1] else null
val isFirstInGroup = next == null || next.senderName != message.senderName val isFirstInGroup = next == null || next.senderName != message.senderName || message.replyTo != null
// Show "NEW" divider after the last unread message // Show "NEW" divider after the last unread message
// In reverse layout, unreadMarkerIndex 0 = newest message // In reverse layout, unreadMarkerIndex 0 = newest message
@@ -298,7 +298,7 @@ fun MessageTimeline(
} }
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage, onSendFile) MessageInput(channelName ?: "message", onSendMessage, onSendFiles)
} }
} }
} }
@@ -752,13 +752,60 @@ private fun formatFileSize(bytes: Long): String {
} }
@Composable @Composable
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFile: (Uri) -> Unit) { private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetMultipleContents()
) { uri: Uri? -> ) { uris: List<Uri> ->
if (uri != null) onSendFile(uri) if (uris.isNotEmpty()) attachedUris = attachedUris + uris
} }
val canSend = text.isNotBlank() || attachedUris.isNotEmpty()
Column(modifier = Modifier.fillMaxWidth()) {
// Attachment previews (Discord-style, above the text box)
if (attachedUris.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
attachedUris.forEach { uri ->
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Remove button
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(2.dp)
.size(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
.clickable { attachedUris = attachedUris - uri },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Close, "Remove",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -784,13 +831,23 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o
), ),
) )
IconButton( IconButton(
onClick = { if (text.isNotBlank()) { onSendMessage(text.trim()); text = "" } }, onClick = {
enabled = text.isNotBlank(), if (attachedUris.isNotEmpty()) {
onSendFiles(attachedUris, text.trim().ifBlank { null })
attachedUris = emptyList()
text = ""
} else if (text.isNotBlank()) {
onSendMessage(text.trim())
text = ""
}
},
enabled = canSend,
) { ) {
Icon( Icon(
Icons.AutoMirrored.Filled.Send, "Send", Icons.AutoMirrored.Filled.Send, "Send",
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
}
} }