nice
This commit is contained in:
17
CLAUDE.md
17
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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user