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
|
- 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).
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user