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
- Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`) for Matrix protocol
- 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,
onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) },
onSendFile = { viewModel.sendFile(it) },
onSendFiles = { uris, caption -> viewModel.sendFiles(uris, caption) },
onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f),

View File

@@ -140,6 +140,8 @@ class MainViewModel(
private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(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)
val unreadMarkerIndex: StateFlow<Int> = _unreadMarkerIndex
@@ -223,7 +225,7 @@ class MainViewModel(
val syncUnreadCount = mutableMapOf<String, ULong>()
for (room in joinedRooms) {
val roomId = room.id()
if (roomId == _selectedChannel.value) continue
if (roomId == _selectedChannel.value || roomId in _recentlyReadRooms.value) continue
try {
val info = room.roomInfo()
val highlight = info.highlightCount
@@ -543,7 +545,8 @@ class MainViewModel(
}
}
} 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
}
is MsgLikeKind.UnableToDecrypt -> {
@@ -594,7 +597,7 @@ class MainViewModel(
return true
}
private fun resolveMessageType(msgType: MessageType, body: String): MessageContent? {
private fun resolveMessageType(msgType: MessageType, body: String, rawJson: String? = null): MessageContent? {
return when (msgType) {
is MessageType.Text -> {
val text = msgType.content.body
@@ -636,14 +639,10 @@ 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)
// Detect Discord bridge GIFs: fi.mau.gif in raw event, or tenor/giphy body URL
val isGifVideo = (rawJson != null && rawJson.contains("\"fi.mau.gif\"")) ||
body.contains("tenor.com/") ||
body.contains("giphy.com/")
if (isGifVideo) {
MessageContent.Gif(
body = c.filename,
@@ -752,44 +751,46 @@ class MainViewModel(
}
}
fun sendFile(uri: Uri) {
fun sendFiles(uris: List<Uri>, caption: String?) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val contentResolver = application.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) cursor.getString(idx) else null
} else null
} ?: "file"
for (uri in uris) {
try {
val contentResolver = application.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) cursor.getString(idx) else null
} else null
} ?: "file"
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: return@launch
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: 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(
source = UploadSource.Data(bytes, fileName),
caption = null,
formattedCaption = null,
mentions = null,
inReplyTo = null,
)
val params = UploadParameters(
source = UploadSource.Data(bytes, fileName),
caption = caption,
formattedCaption = null,
mentions = null,
inReplyTo = null,
)
timeline.sendFile(
params = params,
fileInfo = org.matrix.rustcomponents.sdk.FileInfo(
mimetype = mimeType,
size = bytes.size.toULong(),
thumbnailInfo = null,
thumbnailSource = null,
),
)
timeline.sendFile(
params = params,
fileInfo = org.matrix.rustcomponents.sdk.FileInfo(
mimetype = mimeType,
size = bytes.size.toULong(),
thumbnailInfo = null,
thumbnailSource = null,
),
)
} catch (e: Exception) {
android.util.Log.e("SendFile", "Failed to send file", e)
} catch (e: Exception) {
android.util.Log.e("SendFile", "Failed to send file", e)
}
}
}
}
@@ -1050,12 +1051,18 @@ class MainViewModel(
private fun sendReadReceipt(roomId: String) {
val client = authRepository.getClient() ?: return
// Suppress unread status while the receipt is in flight
_recentlyReadRooms.value = _recentlyReadRooms.value + roomId
viewModelScope.launch(Dispatchers.IO) {
try {
val room = client.getRoom(roomId) ?: return@launch
room.markAsRead(org.matrix.rustcomponents.sdk.ReceiptType.READ)
} catch (e: Exception) {
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>,
onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit,
onSendFile: (Uri) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit,
onLoadMore: () -> Unit = {},
unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier,
@@ -229,7 +229,7 @@ fun MessageTimeline(
) { index ->
val message = messages[index]
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
// In reverse layout, unreadMarkerIndex 0 = newest message
@@ -298,7 +298,7 @@ fun MessageTimeline(
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage, onSendFile)
MessageInput(channelName ?: "message", onSendMessage, onSendFiles)
}
}
}
@@ -752,45 +752,102 @@ private fun formatFileSize(bytes: Long): String {
}
@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 attachedUris by remember { mutableStateOf(listOf<Uri>()) }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) onSendFile(uri)
contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> ->
if (uris.isNotEmpty()) attachedUris = attachedUris + uris
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = { filePickerLauncher.launch("*/*") }) {
Icon(
Icons.Default.AttachFile, "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
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,
)
}
}
}
}
}
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp),
maxLines = 8,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
)
IconButton(
onClick = { if (text.isNotBlank()) { onSendMessage(text.trim()); text = "" } },
enabled = text.isNotBlank(),
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.Send, "Send",
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
IconButton(onClick = { filePickerLauncher.launch("*/*") }) {
Icon(
Icons.Default.AttachFile, "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp),
maxLines = 8,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
)
IconButton(
onClick = {
if (attachedUris.isNotEmpty()) {
onSendFiles(attachedUris, text.trim().ifBlank { null })
attachedUris = emptyList()
text = ""
} else if (text.isNotBlank()) {
onSendMessage(text.trim())
text = ""
}
},
enabled = canSend,
) {
Icon(
Icons.AutoMirrored.Filled.Send, "Send",
tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}