Add chat UX improvements and fix DM naming sync
This commit is contained in:
parent
700289fef9
commit
b30e83898c
10 changed files with 704 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue