diff --git a/CLAUDE.md b/CLAUDE.md index 7b5f7df..9c5b4b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index ff1d4db..012e15f 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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), diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index 09a64df..a501023 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -140,6 +140,8 @@ class MainViewModel( private val _roomUnreadStatus = MutableStateFlow>(emptyMap()) private val _roomUnreadCount = MutableStateFlow>(emptyMap()) + // Rooms we've marked as read but server may not have processed yet + private val _recentlyReadRooms = MutableStateFlow>(emptySet()) private val _unreadMarkerIndex = MutableStateFlow(-1) val unreadMarkerIndex: StateFlow = _unreadMarkerIndex @@ -223,7 +225,7 @@ class MainViewModel( val syncUnreadCount = mutableMapOf() 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, 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 } } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 9b764d8..763273e 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -126,7 +126,7 @@ fun MessageTimeline( messages: List, onToggleMemberList: () -> Unit, onSendMessage: (String) -> Unit, - onSendFile: (Uri) -> Unit, + onSendFiles: (List, 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, String?) -> Unit) { var text by remember { mutableStateOf("") } + var attachedUris by remember { mutableStateOf(listOf()) } val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - if (uri != null) onSendFile(uri) + contract = ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + 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, + ) + } } } }