Add chat UX improvements and fix DM naming sync

This commit is contained in:
ltadeu6 2026-05-16 16:24:32 -03:00
parent 700289fef9
commit b30e83898c
10 changed files with 704 additions and 102 deletions

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@ -70,6 +71,7 @@ fun LoginScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 24.dp, vertical = 48.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start

View file

@ -1,5 +1,7 @@
package com.ltadeu6.matrix.chat
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -17,14 +19,18 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -41,6 +47,7 @@ import com.ltadeu6.matrix.ui.theme.MatrixGreen
import com.ltadeu6.matrix.ui.theme.MatrixGreenDim
import com.ltadeu6.matrix.ui.theme.TerminalFont
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatScreen(
roomId: String,
@ -55,11 +62,18 @@ fun ChatScreen(
val messages by vm.messages.collectAsState()
val roomName by vm.roomName.collectAsState()
val isEncrypted by vm.isEncrypted.collectAsState()
val canLoadMore by vm.canLoadMore.collectAsState()
val loadingOlder by vm.loadingOlder.collectAsState()
val inputText by vm.inputText.collectAsState()
val searchQuery by vm.searchQuery.collectAsState()
val sendError by vm.sendError.collectAsState()
val editingEventId by vm.editingEventId.collectAsState()
val listState = rememberLazyListState()
var selectedMessage by remember { mutableStateOf<Message?>(null) }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(messages.size - 1)
}
}
@ -90,12 +104,75 @@ fun ChatScreen(
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.End
) {
Text(
text = if (isEncrypted) "[e2ee]" else "[plain]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
)
}
Text(
text = "".repeat(50),
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
modifier = Modifier.padding(horizontal = 8.dp)
)
OutlinedTextField(
value = searchQuery,
onValueChange = vm::setSearchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
placeholder = {
Text(
"search loaded messages...",
style = TextStyle(fontFamily = TerminalFont, color = MatrixBorder)
)
},
textStyle = TextStyle(fontFamily = TerminalFont, fontSize = 13.sp, color = MatrixGreen),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MatrixGreen,
unfocusedBorderColor = MatrixBorder,
cursorColor = MatrixGreen,
focusedTextColor = MatrixGreen,
unfocusedTextColor = MatrixGreen,
focusedContainerColor = MatrixBackground,
unfocusedContainerColor = MatrixBackground
)
)
if (editingEventId != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "editing message...",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
Spacer(Modifier.weight(1f))
TextButton(onClick = vm::cancelEditing, contentPadding = PaddingValues(0.dp)) {
Text("cancel", style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixBorder))
}
}
}
sendError?.let { error ->
Text(
text = "! $error",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = androidx.compose.ui.graphics.Color(0xFFFF4444)),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Message list
LazyColumn(
state = listState,
@ -105,11 +182,29 @@ fun ChatScreen(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (canLoadMore) {
item("load-older") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(onClick = vm::loadOlderMessages, enabled = !loadingOlder) {
Text(
text = if (loadingOlder) "[loading older...]" else "[load older]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 12.sp, color = MatrixGreenDim)
)
}
}
}
}
items(
items = messages,
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
) { msg ->
MessageRow(msg)
MessageRow(
msg = msg,
onLongPress = { selectedMessage = msg }
)
}
}
@ -161,29 +256,81 @@ fun ChatScreen(
)
}
}
selectedMessage?.let { message ->
AlertDialog(
containerColor = MatrixBackground,
onDismissRequest = { selectedMessage = null },
title = {
Text(
text = "message actions",
style = TextStyle(fontFamily = TerminalFont, fontSize = 16.sp, color = MatrixGreen)
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
vm.reactTo(message.eventId, "👍")
selectedMessage = null
}) { Text("react 👍", fontFamily = TerminalFont, color = MatrixGreen) }
TextButton(onClick = {
vm.reactTo(message.eventId, "❤️")
selectedMessage = null
}) { Text("react ❤️", fontFamily = TerminalFont, color = MatrixGreen) }
TextButton(onClick = {
vm.reactTo(message.eventId, "👀")
selectedMessage = null
}) { Text("react 👀", fontFamily = TerminalFont, color = MatrixGreen) }
if (message.isOutgoing) {
TextButton(onClick = {
vm.startEditing(message)
selectedMessage = null
}) { Text("edit", fontFamily = TerminalFont, color = MatrixGreen) }
}
}
},
confirmButton = {
TextButton(onClick = { selectedMessage = null }) {
Text("close", fontFamily = TerminalFont, color = MatrixBorder)
}
}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageRow(msg: Message) {
private fun MessageRow(msg: Message, onLongPress: () -> Unit) {
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
if (msg.isOutgoing) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = {}, onLongClick = onLongPress),
horizontalArrangement = Arrangement.End
) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
),
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.widthIn(max = 300.dp)
)
) {
Text(
text = "> ${msg.body}",
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 14.sp,
color = MatrixGreenDim
)
)
MessageMeta(msg)
}
}
} else {
Column(modifier = Modifier.widthIn(max = 300.dp)) {
Column(
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
) {
Text(
text = "< $shortSender",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
@ -192,6 +339,23 @@ private fun MessageRow(msg: Message) {
text = " ${msg.body}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
)
MessageMeta(msg)
}
}
}
@Composable
private fun MessageMeta(msg: Message) {
val pieces = buildList {
if (msg.isEdited) add("edited")
if (msg.reactions.isNotEmpty()) {
add(msg.reactions.entries.joinToString(" ") { "${it.key} ${it.value}" })
}
}
if (pieces.isNotEmpty()) {
Text(
text = pieces.joinToString(" "),
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
)
}
}

View file

@ -5,7 +5,9 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.Message
import com.ltadeu6.matrix.matrix.Room
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -18,27 +20,104 @@ class ChatViewModel(
val roomId: String
) : ViewModel() {
val messages: StateFlow<List<Message>> = session.syncState
private val roomState: StateFlow<Room?> = session.syncState
.map { it.rooms[roomId] }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val allMessages: StateFlow<List<Message>> = session.syncState
.map { it.messages[roomId] ?: emptyList() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val roomName: StateFlow<String> = session.syncState
.map { it.rooms[roomId]?.name ?: roomId }
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _sendError = MutableStateFlow<String?>(null)
val sendError: StateFlow<String?> = _sendError.asStateFlow()
private val _loadingOlder = MutableStateFlow(false)
val loadingOlder: StateFlow<Boolean> = _loadingOlder.asStateFlow()
private val _editingEventId = MutableStateFlow<String?>(null)
val editingEventId: StateFlow<String?> = _editingEventId.asStateFlow()
val roomName: StateFlow<String> = roomState
.map { it?.name ?: roomId }
.stateIn(viewModelScope, SharingStarted.Eagerly, roomId)
val isEncrypted: StateFlow<Boolean> = roomState
.map { it?.isEncrypted == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val canLoadMore: StateFlow<Boolean> = roomState
.map { it?.canLoadMore == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val messages: StateFlow<List<Message>> = combine(allMessages, searchQuery) { messages, query ->
val term = query.trim()
if (term.isBlank()) messages
else messages.filter {
it.body.contains(term, ignoreCase = true) || it.sender.contains(term, ignoreCase = true)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
fun setInput(text: String) {
_sendError.value = null
_inputText.value = text
}
fun setSearchQuery(text: String) {
_searchQuery.value = text
}
fun clearSendError() {
_sendError.value = null
}
fun startEditing(message: Message) {
if (!message.isOutgoing) return
_editingEventId.value = message.eventId
_inputText.value = message.body
_sendError.value = null
}
fun cancelEditing() {
_editingEventId.value = null
_inputText.value = ""
}
fun reactTo(eventId: String, key: String) {
viewModelScope.launch {
session.sendReaction(roomId, eventId, key)
.onFailure { _sendError.value = it.message ?: "failed to send reaction" }
}
}
fun loadOlderMessages() {
if (_loadingOlder.value) return
viewModelScope.launch {
_loadingOlder.value = true
session.loadOlderMessages(roomId)
.onFailure { _sendError.value = it.message ?: "failed to load older messages" }
_loadingOlder.value = false
}
}
fun sendMessage() {
val text = _inputText.value.trim()
if (text.isBlank()) return
_inputText.value = ""
val editingEventId = _editingEventId.value
viewModelScope.launch {
session.sendMessage(roomId, text)
val result = if (editingEventId != null) {
session.editMessage(roomId, editingEventId, text)
} else {
session.sendMessage(roomId, text)
}
result.onSuccess {
_inputText.value = ""
_editingEventId.value = null
_sendError.value = null
}.onFailure {
_sendError.value = it.message ?: if (editingEventId != null) "failed to edit message" else "failed to send message"
}
}
}
}

View file

@ -201,18 +201,23 @@ class CryptoService(
// Room message decryption / encryption
// -------------------------------------------------------------------------
fun decryptRoomEvent(content: JsonObject): String? {
fun decryptRoomEventPayload(content: JsonObject): JsonObject? {
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
val session = inboundSessions[sessionId] ?: return null
return try {
val result = session.decryptMessage(ciphertext)
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
json.parseToJsonElement(result.mDecryptedMessage).jsonObject
} catch (_: Exception) { null }
}
fun decryptRoomEvent(content: JsonObject): String? =
(decryptRoomEventPayload(content)
?.get("content")?.jsonObject
?.get("body") as? JsonPrimitive)
?.content
fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? {
val session = outboundSessions[roomId] ?: return null
return try {

View file

@ -63,6 +63,12 @@ interface MatrixApi {
@Body request: CreateRoomRequest
): CreateRoomResponse
@GET("/_matrix/client/v3/user/{userId}/account_data/m.direct")
suspend fun getDirectRooms(
@Header("Authorization") auth: String,
@Path("userId") userId: String
): JsonObject
@POST("/_matrix/client/v3/rooms/{roomId}/leave")
suspend fun leaveRoom(
@Header("Authorization") auth: String,
@ -101,6 +107,15 @@ interface MatrixApi {
@Path("roomId") roomId: String
): JoinedMembersResponse
@GET("/_matrix/client/v3/rooms/{roomId}/messages")
suspend fun getMessages(
@Header("Authorization") auth: String,
@Path("roomId") roomId: String,
@Query("from") from: String,
@Query("dir") dir: String = "b",
@Query("limit") limit: Int = 50
): MessagesResponse
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
suspend fun sendToDevice(
@Header("Authorization") auth: String,

View file

@ -92,7 +92,8 @@ data class JoinedRoomSync(
@Serializable
data class Timeline(
val events: List<RoomEvent> = emptyList(),
@SerialName("limited") val limited: Boolean = false
@SerialName("limited") val limited: Boolean = false,
@SerialName("prev_batch") val prevBatch: String? = null
)
@Serializable
@ -157,11 +158,20 @@ data class MemberInfo(
@SerialName("avatar_url") val avatarUrl: String? = null
)
@Serializable
data class MessagesResponse(
val chunk: List<RoomEvent> = emptyList(),
val end: String? = null,
val start: String? = null
)
// --- Local UI models ---
data class Room(
val id: String,
val name: String
val name: String,
val isEncrypted: Boolean = false,
val canLoadMore: Boolean = false
)
data class Message(
@ -169,5 +179,7 @@ data class Message(
val sender: String,
val body: String,
val timestamp: Long,
val isOutgoing: Boolean
val isOutgoing: Boolean,
val isEdited: Boolean = false,
val reactions: Map<String, Int> = emptyMap()
)

View file

@ -62,6 +62,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private var syncJob: Job? = null
private var api: MatrixApi? = null
private var cryptoService: CryptoService? = null
private val roomEventCache = mutableMapOf<String, MutableList<RoomEvent>>()
private val roomPrevBatches = mutableMapOf<String, String?>()
private val directRoomTargets = mutableMapOf<String, String>()
/**
* SAS device verification service. Created once; userId/deviceId/cryptoService
@ -176,6 +179,9 @@ class MatrixSession private constructor(private val appContext: Context) {
encryptedRooms.clear()
sharedSessionRooms.clear()
requestedSessions.clear()
roomEventCache.clear()
roomPrevBatches.clear()
directRoomTargets.clear()
verificationService.dismiss()
verificationService.userId = ""
verificationService.deviceId = ""
@ -185,30 +191,66 @@ class MatrixSession private constructor(private val appContext: Context) {
}
suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.room.message",
content = buildJsonObject {
put("msgtype", "m.text")
put("body", body)
}
)
}
suspend fun editMessage(roomId: String, eventId: String, body: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.room.message",
content = buildJsonObject {
put("msgtype", "m.text")
put("body", "* $body")
put("m.new_content", buildJsonObject {
put("msgtype", "m.text")
put("body", body)
})
put("m.relates_to", buildJsonObject {
put("rel_type", "m.replace")
put("event_id", eventId)
})
}
)
}
suspend fun sendReaction(roomId: String, eventId: String, key: String): Result<Unit> {
return sendTimelineEvent(
roomId = roomId,
eventType = "m.reaction",
content = buildJsonObject {
put("m.relates_to", buildJsonObject {
put("rel_type", "m.annotation")
put("event_id", eventId)
put("key", key)
})
}
)
}
suspend fun loadOlderMessages(roomId: String, limit: Int = 50): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
val from = roomPrevBatches[roomId] ?: return Result.failure(Exception("No older messages"))
return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
val crypto = cryptoService
if (crypto != null && roomId in encryptedRooms) {
// Share our outbound session with room members if we haven't yet
if (roomId !in sharedSessionRooms) {
shareGroupSession(roomId)
sharedSessionRooms.add(roomId)
}
val eventPayload = buildJsonObject {
put("room_id", roomId)
put("type", "m.room.message")
put("content", buildJsonObject {
put("msgtype", "m.text")
put("body", body)
})
}.toString()
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
?: throw Exception("Encryption failed — outbound session not established")
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
} else {
matrixApi.sendMessage("Bearer $token", roomId, txnId, SendMessageRequest(body = body))
val response = matrixApi.getMessages("Bearer $token", roomId, from = from, limit = limit)
mergeRoomEvents(roomId, response.chunk)
roomPrevBatches[roomId] = response.end
val current = _syncState.value
val rooms = current.rooms.toMutableMap()
val messages = current.messages.toMutableMap()
val missingSessions = mutableListOf<MissingSession>()
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
_syncState.value = SyncState(rooms = rooms, messages = messages)
if (missingSessions.isNotEmpty()) {
sendKeyRequests(missingSessions, "Bearer $token", matrixApi)
}
}
}
@ -228,6 +270,8 @@ class MatrixSession private constructor(private val appContext: Context) {
)
encryptedRooms.remove(roomId)
sharedSessionRooms.remove(roomId)
roomEventCache.remove(roomId)
roomPrevBatches.remove(roomId)
}
}
@ -295,6 +339,36 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
private suspend fun sendTimelineEvent(
roomId: String,
eventType: String,
content: JsonObject
): Result<Unit> {
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
val matrixApi = api ?: return Result.failure(Exception("No API client"))
return runCatching {
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
val crypto = cryptoService
if (roomId in encryptedRooms) {
if (crypto == null) throw Exception("Encryption unavailable for this room")
if (roomId !in sharedSessionRooms) {
shareGroupSession(roomId)
sharedSessionRooms.add(roomId)
}
val eventPayload = buildJsonObject {
put("room_id", roomId)
put("type", eventType)
put("content", content)
}.toString()
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
?: throw Exception("Encryption failed — outbound session not established")
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
} else {
matrixApi.sendEvent("Bearer $token", roomId, eventType, txnId, content)
}
}
}
/**
* Sends `m.room_key_request` to-device events to all of our own devices (`*`) for every
* session we failed to decrypt. Deduplicates by `roomId+sessionId` so we only ask once.
@ -490,6 +564,7 @@ class MatrixSession private constructor(private val appContext: Context) {
if (isFirstSync) {
isFirstSync = false
refreshDirectRoomTargets(token, matrixApi)
fetchMissingRoomNames(token, matrixApi)
}
} catch (e: CancellationException) {
@ -501,15 +576,48 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
private suspend fun refreshDirectRoomTargets(token: String, matrixApi: MatrixApi) {
val userId = currentUserId ?: return
val direct = runCatching { matrixApi.getDirectRooms("Bearer $token", userId) }.getOrNull() ?: return
directRoomTargets.clear()
direct.forEach { (mxid, roomIdsJson) ->
val roomIds = roomIdsJson as? JsonArray ?: return@forEach
roomIds.forEach { roomIdJson ->
val roomId = (roomIdJson as? JsonPrimitive)?.content ?: return@forEach
directRoomTargets[roomId] = mxid
}
}
}
private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) {
val current = _syncState.value
val updates = mutableMapOf<String, Room>()
current.rooms.forEach { (roomId, room) ->
if (room.name == roomId) {
val directTarget = directRoomTargets[roomId]
val shouldResolve = directTarget != null || room.name == roomId || room.name.isBlank()
if (shouldResolve) {
val name = runCatching { matrixApi.getRoomName("Bearer $token", roomId).name }
.getOrNull()?.takeIf { it.isNotBlank() }
.getOrNull()
?.takeIf { it.isNotBlank() && directTarget == null }
?: runCatching { matrixApi.getRoomAlias("Bearer $token", roomId).alias }
.getOrNull()?.takeIf { it.isNotBlank() }
.getOrNull()
?.takeIf { it.isNotBlank() && directTarget == null }
?: runCatching {
val joined = matrixApi.getJoinedMembers("Bearer $token", roomId).joined
val preferred = directTarget?.let { target ->
joined[target]?.displayName?.takeIf { it.isNotBlank() } ?: joined[target]?.let { target }
}
if (preferred != null) {
preferred
} else {
val others = joined.entries.filter { it.key != currentUserId }
if (others.size == 1) {
others.first().value.displayName?.takeIf { it.isNotBlank() } ?: others.first().key
} else {
null
}
}
}.getOrNull()
if (name != null) updates[roomId] = room.copy(name = name)
}
}
@ -518,6 +626,10 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
private fun prefersMemberName(roomId: String): Boolean {
return directRoomTargets.containsKey(roomId)
}
private fun processSyncResponse(response: SyncResponse): List<MissingSession> {
val current = _syncState.value
val rooms = current.rooms.toMutableMap()
@ -532,60 +644,173 @@ class MatrixSession private constructor(private val appContext: Context) {
val nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" }
val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" }
val resolvedName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content
val explicitName = (nameEvent?.content?.get("name") as? JsonPrimitive)?.content
?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content
?: rooms[roomId]?.name
?: roomId
rooms[roomId] = Room(id = roomId, name = resolvedName)
val existingName = rooms[roomId]?.name
val resolvedName = if (prefersMemberName(roomId)) {
existingName?.takeIf { it.isNotBlank() && it != roomId }
?: explicitName
?: roomId
} else {
explicitName
?: existingName
?: roomId
}
// Track encryption state
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
.any { it.type == "m.room.encryption" }
if (roomIsEncrypted) encryptedRooms.add(roomId)
val newMsgs = roomData.timeline?.events
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" }
?.mapNotNull { event ->
val body = when (event.type) {
"m.room.message" ->
(event.content?.get("body") as? JsonPrimitive)?.content
?: return@mapNotNull null
"m.room.encrypted" -> tryDecrypt(event, roomId, missingSessions)
else -> return@mapNotNull null
}
Message(
eventId = event.eventId,
sender = event.sender,
body = body,
timestamp = event.timestamp,
isOutgoing = event.sender == currentUserId
)
} ?: emptyList()
if (newMsgs.isNotEmpty()) {
val existing = messages[roomId] ?: emptyList()
val existingIds = existing.map { it.eventId }.toSet()
val merged = existing + newMsgs.filter { it.eventId !in existingIds }
messages[roomId] = merged.sortedBy { it.timestamp }
}
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
rooms[roomId] = Room(
id = roomId,
name = resolvedName,
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
)
mergeRoomEvents(roomId, roomData.timeline?.events ?: emptyList())
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions
}
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String {
val content = event.content ?: return "[encrypted message]"
val result = cryptoService?.decryptRoomEvent(content)
if (result == null) {
val sessionId = (content["session_id"] as? JsonPrimitive)?.content
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content
if (sessionId != null && senderKey != null) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
private fun mergeRoomEvents(roomId: String, events: List<RoomEvent>) {
if (events.isEmpty()) return
val existing = roomEventCache.getOrPut(roomId) { mutableListOf() }
val knownIds = existing.mapTo(mutableSetOf()) { it.eventId }
events.forEach { event ->
val dedupeKey = event.eventId.ifEmpty { "${event.type}:${event.sender}:${event.timestamp}" }
if (dedupeKey !in knownIds) {
existing.add(event)
knownIds.add(dedupeKey)
}
}
return result ?: "[encrypted message]"
}
private fun rebuildRoomMessages(
roomId: String,
rooms: MutableMap<String, Room>,
messages: MutableMap<String, List<Message>>,
missingSessions: MutableList<MissingSession>
) {
val cachedEvents = roomEventCache[roomId].orEmpty().sortedBy { it.timestamp }
val builtMessages = mutableListOf<Message>()
val messageIndexById = mutableMapOf<String, Int>()
val pendingEdits = mutableMapOf<String, String>()
val pendingReactions = mutableMapOf<String, MutableMap<String, Int>>()
cachedEvents.forEach { event ->
val decoded = decodeTimelineEvent(roomId, event, missingSessions) ?: return@forEach
val relatesTo = decoded.content["m.relates_to"]?.jsonObject
val relType = (relatesTo?.get("rel_type") as? JsonPrimitive)?.content
when {
decoded.type == "m.reaction" && relType == "m.annotation" -> {
val targetId = (relatesTo["event_id"] as? JsonPrimitive)?.content ?: return@forEach
val reactionKey = (relatesTo["key"] as? JsonPrimitive)?.content ?: return@forEach
val idx = messageIndexById[targetId]
if (idx != null) {
val current = builtMessages[idx]
val updated = current.reactions.toMutableMap()
updated[reactionKey] = (updated[reactionKey] ?: 0) + 1
builtMessages[idx] = current.copy(reactions = updated.toSortedMap())
} else {
val pending = pendingReactions.getOrPut(targetId) { mutableMapOf() }
pending[reactionKey] = (pending[reactionKey] ?: 0) + 1
}
}
decoded.type == "m.room.message" && relType == "m.replace" -> {
val targetId = (relatesTo["event_id"] as? JsonPrimitive)?.content ?: return@forEach
val newBody = (decoded.content["m.new_content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
?: (decoded.content["body"] as? JsonPrimitive)?.content
?: return@forEach
val idx = messageIndexById[targetId]
if (idx != null) {
val current = builtMessages[idx]
builtMessages[idx] = current.copy(body = newBody, isEdited = true)
} else {
pendingEdits[targetId] = newBody
}
}
decoded.type == "m.room.message" -> {
val body = (decoded.content["body"] as? JsonPrimitive)?.content ?: return@forEach
val editedBody = pendingEdits.remove(event.eventId)
val message = Message(
eventId = event.eventId,
sender = event.sender,
body = editedBody ?: body,
timestamp = event.timestamp,
isOutgoing = event.sender == currentUserId,
isEdited = editedBody != null,
reactions = pendingReactions.remove(event.eventId)?.toSortedMap() ?: emptyMap()
)
messageIndexById[event.eventId] = builtMessages.size
builtMessages.add(message)
}
}
}
messages[roomId] = builtMessages
rooms[roomId] = rooms[roomId]?.copy(
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
) ?: Room(
id = roomId,
name = roomId,
isEncrypted = roomId in encryptedRooms,
canLoadMore = roomPrevBatches[roomId] != null
)
}
private data class DecodedTimelineEvent(
val type: String,
val content: JsonObject
)
private fun decodeTimelineEvent(
roomId: String,
event: RoomEvent,
missingSessions: MutableList<MissingSession>
): DecodedTimelineEvent? {
return when (event.type) {
"m.room.message", "m.reaction" -> {
val content = event.content ?: return null
DecodedTimelineEvent(event.type, content)
}
"m.room.encrypted" -> {
val encryptedContent = event.content ?: return DecodedTimelineEvent(
type = "m.room.message",
content = buildJsonObject {
put("body", "[encrypted message]")
}
)
val decrypted = cryptoService?.decryptRoomEventPayload(encryptedContent)
if (decrypted == null) {
val sessionId = (encryptedContent["session_id"] as? JsonPrimitive)?.content
val senderKey = (encryptedContent["sender_key"] as? JsonPrimitive)?.content
if (sessionId != null && senderKey != null) {
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
}
DecodedTimelineEvent(
type = "m.room.message",
content = buildJsonObject {
put("body", "[encrypted message]")
}
)
} else {
val innerType = (decrypted["type"] as? JsonPrimitive)?.content ?: return null
val innerContent = decrypted["content"]?.jsonObject ?: return null
DecodedTimelineEvent(innerType, innerContent)
}
}
else -> null
}
}
// -------------------------------------------------------------------------

View file

@ -61,6 +61,7 @@ class VerificationService(private val scope: CoroutineScope) {
private var pendingTheirMac: JsonObject? = null
private var selectedMacMethod: String? = null
private var hasConfirmedLocally: Boolean = false
private var hasVerifiedTheirMac: Boolean = false
fun processToDeviceEvent(event: JsonObject) {
val type = (event["type"] as? JsonPrimitive)?.content ?: return
@ -174,6 +175,7 @@ class VerificationService(private val scope: CoroutineScope) {
val sasBytes = sas.generateShortCode(info, 6)
val emojis = sasBytes.toSasEmojis()
hasConfirmedLocally = false
hasVerifiedTheirMac = false
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
}
@ -272,6 +274,7 @@ class VerificationService(private val scope: CoroutineScope) {
val doneContent = buildJsonObject { put("transaction_id", txnId) }
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
hasVerifiedTheirMac = true
cleanup()
_state.value = VerificationState.Done
onVerified()
@ -280,6 +283,9 @@ class VerificationService(private val scope: CoroutineScope) {
private fun handleDone(txnId: String) {
if (txnId != currentTxnId) return
if (!hasVerifiedTheirMac) {
return
}
cleanup()
_state.value = VerificationState.Done
onVerified()
@ -330,6 +336,7 @@ class VerificationService(private val scope: CoroutineScope) {
pendingTheirMac = null
selectedMacMethod = null
hasConfirmedLocally = false
hasVerifiedTheirMac = false
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
@ -73,6 +74,7 @@ fun RoomListScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
@ -116,7 +118,11 @@ fun RoomListScreen(
if (actionError != null) {
Text(
text = " ${actionError}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixGreenDim)
style = TextStyle(
fontFamily = TerminalFont,
fontSize = 11.sp,
color = androidx.compose.ui.graphics.Color(0xFFFF4444)
)
)
}
@ -303,10 +309,26 @@ private fun RoomRow(room: Room, onClick: () -> Unit, onLongPress: () -> Unit) {
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
.padding(vertical = 10.dp, horizontal = 4.dp)
) {
Text(
text = "> ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "> ${room.name}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
)
val status = buildList {
if (room.isEncrypted) add("e2ee")
if (room.canLoadMore) add("history")
}.joinToString(" | ")
if (status.isNotBlank()) {
Text(
text = "[$status]",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixGreenDim)
)
}
}
Text(
text = " ${room.id}",
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)