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
81
AGENTS.md
81
AGENTS.md
|
|
@ -9,10 +9,13 @@ Progress log and architecture notes for AI agents working on this project.
|
||||||
| Component | Status |
|
| Component | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Build infrastructure (Gradle, version catalog) | done |
|
| Build infrastructure (Gradle, version catalog) | done |
|
||||||
|
| NixOS CLI dev shell / Android SDK toolchain | done |
|
||||||
| Matrix networking (Retrofit API + Session) | done |
|
| Matrix networking (Retrofit API + Session) | done |
|
||||||
| SSO / password login flow | done |
|
| SSO / password login flow | done |
|
||||||
| Room list screen | done |
|
| Room list screen | done |
|
||||||
| Chat screen (send + receive) | done |
|
| Chat screen (send + receive) | done |
|
||||||
|
| E2EE via local Olm wrapper | done |
|
||||||
|
| Device verification (SAS) | done |
|
||||||
| Terminal theme (green on black, monospace) | done |
|
| Terminal theme (green on black, monospace) | done |
|
||||||
| Launcher icon (vector adaptive) | done |
|
| Launcher icon (vector adaptive) | done |
|
||||||
| README | done |
|
| README | done |
|
||||||
|
|
@ -24,6 +27,23 @@ Progress log and architecture notes for AI agents working on this project.
|
||||||
### No heavy Matrix SDK
|
### No heavy Matrix SDK
|
||||||
Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support.
|
Instead of importing the full `matrix-android-sdk2` (used by Element), we call the Matrix REST API directly with Retrofit. This keeps the APK small and the code easy to follow. The trade-off is no E2E encryption support.
|
||||||
|
|
||||||
|
### Local Olm build instead of Maven-native `olm-sdk`
|
||||||
|
The upstream `org.matrix.android:olm-sdk` Maven artifact shipped a `libolm.so` that was only 4 KB ELF-aligned on arm64, which triggered Android's 16 KB page-size compatibility warning on modern devices. The app now vendors:
|
||||||
|
- `app/libs/olm-sdk-3.2.16.jar`
|
||||||
|
- `app/src/main/jniLibs/*/libolm.so`
|
||||||
|
|
||||||
|
Those JNI binaries were rebuilt locally from the Matrix `olm` Android wrapper with linker flags that produce 16 KB-aligned `LOAD` segments on arm64.
|
||||||
|
|
||||||
|
### Nix-first CLI builds
|
||||||
|
The repo includes a `flake.nix` dev shell for NixOS. It provisions:
|
||||||
|
- JDK 17
|
||||||
|
- Gradle
|
||||||
|
- Android SDK platforms 31 and 35
|
||||||
|
- Build Tools 30.0.2, 34.0.0, and 35.0.0
|
||||||
|
- Android NDK
|
||||||
|
|
||||||
|
The shell rewrites `local.properties` on entry so CLI builds use the Nix-provided SDK path.
|
||||||
|
|
||||||
### SSO via Custom Tabs
|
### SSO via Custom Tabs
|
||||||
Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token.
|
Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom Tab. After auth the homeserver redirects to `matrixandroid://sso?loginToken=<token>`. The app intercepts this URI via an `<intent-filter>` in `AndroidManifest.xml` and exchanges the token for an access token.
|
||||||
|
|
||||||
|
|
@ -33,6 +53,14 @@ Login opens the homeserver's `/login/sso/redirect` endpoint in a Chrome Custom T
|
||||||
### MatrixSession singleton
|
### MatrixSession singleton
|
||||||
`MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`.
|
`MatrixSession` is a process-scoped singleton (companion object). It holds the Retrofit client, persists the access token in `SharedPreferences`, and runs the `/sync` long-polling loop in a coroutine scope. All screens observe `syncState: StateFlow<SyncState>`.
|
||||||
|
|
||||||
|
### Timeline is event-cache driven
|
||||||
|
`MatrixSession` now keeps a per-room in-memory `RoomEvent` cache and rebuilds the visible `Message` list from those events. This allows:
|
||||||
|
- local filtering/search over loaded events
|
||||||
|
- message edits (`m.replace`)
|
||||||
|
- reactions (`m.annotation`)
|
||||||
|
- incremental history pagination
|
||||||
|
- retrying decryption after keys arrive from another device
|
||||||
|
|
||||||
### Sync filter
|
### Sync filter
|
||||||
Initial and incremental syncs use a compact filter:
|
Initial and incremental syncs use a compact filter:
|
||||||
- Timeline limited to 50 events per room
|
- Timeline limited to 50 events per room
|
||||||
|
|
@ -45,13 +73,14 @@ This prevents the first sync from being huge on accounts with many rooms.
|
||||||
|
|
||||||
## Known issues / future work
|
## Known issues / future work
|
||||||
|
|
||||||
- **E2E encryption**: Matrix Olm/Megolm is not implemented. Encrypted rooms will show blank messages.
|
- **Olm is still legacy crypto**: The app uses a locally rebuilt Olm wrapper. Matrix has deprecated libolm in favor of vodozemac. A future migration should replace the current JNI/JAR setup.
|
||||||
|
- **SAS verification edge case**: A handshake-ordering fix is currently in progress. The known symptom was that self-verification could fail depending on which device confirmed matching emojis first.
|
||||||
- **Push notifications**: Not implemented. The app only receives messages while open.
|
- **Push notifications**: Not implemented. The app only receives messages while open.
|
||||||
- **Pagination**: No scroll-back pagination for old messages.
|
- **History is partial**: Pagination exists only while the room exposes a `prev_batch` token and only for message history. There is still no durable local database.
|
||||||
- **Room member display names**: Members are shown as their MXID, not display name.
|
|
||||||
- **Image/file messages**: Silently dropped (only `m.text` is rendered).
|
- **Image/file messages**: Silently dropped (only `m.text` is rendered).
|
||||||
- **Error handling**: Network errors on send are silently swallowed; add a snackbar or retry UI.
|
- **Rich message support is partial**: Reactions and edits are handled for loaded events, but there is no reaction picker, no redactions, and no full relation aggregation from the homeserver.
|
||||||
- **Direct messages**: DMs appear in the room list using the room ID; should resolve the other user's display name.
|
- **Search is in-memory only**: Chat search only scans events already loaded into memory for the current room.
|
||||||
|
- **Error handling**: Basic inline send/load errors are shown, but there is still no global offline state, retry queue, or structured error UX.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -75,18 +104,27 @@ matrix-android/
|
||||||
│ │ ├── navigation/AppNavigation.kt
|
│ │ ├── navigation/AppNavigation.kt
|
||||||
│ │ ├── auth/LoginScreen.kt
|
│ │ ├── auth/LoginScreen.kt
|
||||||
│ │ ├── auth/LoginViewModel.kt
|
│ │ ├── auth/LoginViewModel.kt
|
||||||
|
│ │ ├── verification/VerificationScreen.kt
|
||||||
│ │ ├── rooms/RoomListScreen.kt
|
│ │ ├── rooms/RoomListScreen.kt
|
||||||
│ │ ├── chat/ChatScreen.kt
|
│ │ ├── chat/ChatScreen.kt
|
||||||
│ │ ├── chat/ChatViewModel.kt
|
│ │ ├── chat/ChatViewModel.kt
|
||||||
│ │ ├── matrix/MatrixApi.kt
|
│ │ ├── matrix/MatrixApi.kt
|
||||||
│ │ ├── matrix/MatrixModels.kt
|
│ │ ├── matrix/MatrixModels.kt
|
||||||
│ │ ├── matrix/MatrixSession.kt
|
│ │ ├── matrix/MatrixSession.kt
|
||||||
|
│ │ ├── matrix/CryptoService.kt
|
||||||
|
│ │ ├── matrix/VerificationService.kt
|
||||||
│ │ └── ui/theme/Theme.kt
|
│ │ └── ui/theme/Theme.kt
|
||||||
│ └── res/
|
│ └── res/
|
||||||
│ ├── drawable/ic_launcher_foreground.xml
|
│ ├── drawable/ic_launcher_foreground.xml
|
||||||
│ ├── mipmap-anydpi-v26/ic_launcher.xml
|
│ ├── mipmap-anydpi-v26/ic_launcher.xml
|
||||||
│ ├── mipmap-anydpi-v26/ic_launcher_round.xml
|
│ ├── mipmap-anydpi-v26/ic_launcher_round.xml
|
||||||
│ └── values/{strings,colors,themes}.xml
|
│ └── values/{strings,colors,themes}.xml
|
||||||
|
├── flake.nix
|
||||||
|
├── flake.lock
|
||||||
|
├── gradlew
|
||||||
|
├── gradlew.bat
|
||||||
|
├── app/libs/olm-sdk-3.2.16.jar
|
||||||
|
├── app/src/main/jniLibs/
|
||||||
├── README.md
|
├── README.md
|
||||||
└── AGENTS.md
|
└── AGENTS.md
|
||||||
```
|
```
|
||||||
|
|
@ -104,3 +142,36 @@ matrix-android/
|
||||||
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
|
- SSO flow: Custom Tabs → homeserver → `matrixandroid://sso` deep link → token exchange
|
||||||
- Compose UI with monospace font, #00FF41 green, #0A0A0A background
|
- Compose UI with monospace font, #00FF41 green, #0A0A0A background
|
||||||
- All screens text-only, no images/icons/avatars
|
- All screens text-only, no images/icons/avatars
|
||||||
|
|
||||||
|
## Session 2 — NixOS CLI + 16 KB Android compatibility (2026-05-16)
|
||||||
|
|
||||||
|
- Added `flake.nix` + `flake.lock` for NixOS CLI builds
|
||||||
|
- Generated and committed Gradle wrapper files
|
||||||
|
- Discovered Android 16 KB compatibility warning on Pixel 7a
|
||||||
|
- Confirmed APK ZIP alignment was already correct; root cause was Maven `libolm.so` being ELF-aligned to 4 KB
|
||||||
|
- Cloned upstream Matrix `olm` Android wrapper, patched NDK build flags for 16 KB page size, and rebuilt `libolm.so`
|
||||||
|
- Worked around old upstream Android wrapper assumptions:
|
||||||
|
- required platform 31 + build-tools 30.0.2
|
||||||
|
- required NDK path/layout adjustments
|
||||||
|
- required one small C++ compatibility fix in `olm::List` for modern clang/NDK
|
||||||
|
- Replaced `implementation(libs.olm.sdk)` with:
|
||||||
|
- local `app/libs/olm-sdk-3.2.16.jar`
|
||||||
|
- local `app/src/main/jniLibs/*/libolm.so`
|
||||||
|
- Validated final APK on-device:
|
||||||
|
- `libolm.so` inside the APK has `LOAD Align = 0x4000`
|
||||||
|
- APK installed and launched over ADB on the Pixel 7a
|
||||||
|
|
||||||
|
## Session 3 — verification and chat UX expansion (2026-05-16, in progress)
|
||||||
|
|
||||||
|
- Created checkpoint commit `0ede945` before feature work
|
||||||
|
- Began fixing SAS verification handshake ordering
|
||||||
|
- Added event-cache-backed message reconstruction in `MatrixSession`
|
||||||
|
- Added local support for:
|
||||||
|
- in-room search over loaded messages
|
||||||
|
- message edits via `m.replace`
|
||||||
|
- reactions via `m.annotation`
|
||||||
|
- older-message pagination via `/rooms/{roomId}/messages`
|
||||||
|
- room-level encryption/history indicators
|
||||||
|
- inline chat error feedback
|
||||||
|
- Added first-pass DM name resolution fallback via joined members lookup
|
||||||
|
- This session is still dirty in the worktree; verify behavior before treating these changes as final
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -70,6 +71,7 @@ fun LoginScreen(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 24.dp, vertical = 48.dp),
|
.padding(horizontal = 24.dp, vertical = 48.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.Start
|
horizontalAlignment = Alignment.Start
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.ltadeu6.matrix.chat
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.MatrixGreenDim
|
||||||
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
import com.ltadeu6.matrix.ui.theme.TerminalFont
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
roomId: String,
|
roomId: String,
|
||||||
|
|
@ -55,11 +62,18 @@ fun ChatScreen(
|
||||||
|
|
||||||
val messages by vm.messages.collectAsState()
|
val messages by vm.messages.collectAsState()
|
||||||
val roomName by vm.roomName.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 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()
|
val listState = rememberLazyListState()
|
||||||
|
var selectedMessage by remember { mutableStateOf<Message?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size, searchQuery) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty() && searchQuery.isBlank()) {
|
||||||
listState.animateScrollToItem(messages.size - 1)
|
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(
|
||||||
text = "─".repeat(50),
|
text = "─".repeat(50),
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
|
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder),
|
||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
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
|
// Message list
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|
@ -105,11 +182,29 @@ fun ChatScreen(
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.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(
|
||||||
items = messages,
|
items = messages,
|
||||||
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
|
key = { it.eventId.ifEmpty { "${it.sender}:${it.timestamp}" } }
|
||||||
) { msg ->
|
) { msg ->
|
||||||
MessageRow(msg)
|
MessageRow(
|
||||||
|
msg = msg,
|
||||||
|
onLongPress = { selectedMessage = msg }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,16 +256,63 @@ 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
|
@Composable
|
||||||
private fun MessageRow(msg: Message) {
|
private fun MessageRow(msg: Message, onLongPress: () -> Unit) {
|
||||||
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
|
val shortSender = msg.sender.removePrefix("@").substringBefore(":")
|
||||||
|
|
||||||
if (msg.isOutgoing) {
|
if (msg.isOutgoing) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(onClick = {}, onLongClick = onLongPress),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
modifier = Modifier.widthIn(max = 300.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "> ${msg.body}",
|
text = "> ${msg.body}",
|
||||||
|
|
@ -178,12 +320,17 @@ private fun MessageRow(msg: Message) {
|
||||||
fontFamily = TerminalFont,
|
fontFamily = TerminalFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = MatrixGreenDim
|
color = MatrixGreenDim
|
||||||
),
|
|
||||||
modifier = Modifier.widthIn(max = 300.dp)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
MessageMeta(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(modifier = Modifier.widthIn(max = 300.dp)) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.combinedClickable(onClick = {}, onLongClick = onLongPress)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "< $shortSender",
|
text = "< $shortSender",
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
style = TextStyle(fontFamily = TerminalFont, fontSize = 11.sp, color = MatrixBorder)
|
||||||
|
|
@ -192,6 +339,23 @@ private fun MessageRow(msg: Message) {
|
||||||
text = " ${msg.body}",
|
text = " ${msg.body}",
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 14.sp, color = MatrixGreen)
|
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 androidx.lifecycle.viewModelScope
|
||||||
import com.ltadeu6.matrix.matrix.MatrixSession
|
import com.ltadeu6.matrix.matrix.MatrixSession
|
||||||
import com.ltadeu6.matrix.matrix.Message
|
import com.ltadeu6.matrix.matrix.Message
|
||||||
|
import com.ltadeu6.matrix.matrix.Room
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -18,28 +20,105 @@ class ChatViewModel(
|
||||||
val roomId: String
|
val roomId: String
|
||||||
) : ViewModel() {
|
) : 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() }
|
.map { it.messages[roomId] ?: emptyList() }
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 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("")
|
private val _inputText = MutableStateFlow("")
|
||||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
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) {
|
fun setInput(text: String) {
|
||||||
|
_sendError.value = null
|
||||||
_inputText.value = text
|
_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() {
|
fun sendMessage() {
|
||||||
val text = _inputText.value.trim()
|
val text = _inputText.value.trim()
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
_inputText.value = ""
|
val editingEventId = _editingEventId.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val result = if (editingEventId != null) {
|
||||||
|
session.editMessage(roomId, editingEventId, text)
|
||||||
|
} else {
|
||||||
session.sendMessage(roomId, text)
|
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
|
// 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
|
if ((content["algorithm"] as? JsonPrimitive)?.content != "m.megolm.v1.aes-sha2") return null
|
||||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
val sessionId = (content["session_id"] as? JsonPrimitive)?.content ?: return null
|
||||||
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
|
val ciphertext = (content["ciphertext"] as? JsonPrimitive)?.content ?: return null
|
||||||
val session = inboundSessions[sessionId] ?: return null
|
val session = inboundSessions[sessionId] ?: return null
|
||||||
return try {
|
return try {
|
||||||
val result = session.decryptMessage(ciphertext)
|
val result = session.decryptMessage(ciphertext)
|
||||||
val decrypted = json.parseToJsonElement(result.mDecryptedMessage).jsonObject
|
json.parseToJsonElement(result.mDecryptedMessage).jsonObject
|
||||||
(decrypted["content"]?.jsonObject?.get("body") as? JsonPrimitive)?.content
|
|
||||||
} catch (_: Exception) { null }
|
} 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? {
|
fun encryptRoomEvent(roomId: String, plaintext: String): JsonObject? {
|
||||||
val session = outboundSessions[roomId] ?: return null
|
val session = outboundSessions[roomId] ?: return null
|
||||||
return try {
|
return try {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,12 @@ interface MatrixApi {
|
||||||
@Body request: CreateRoomRequest
|
@Body request: CreateRoomRequest
|
||||||
): CreateRoomResponse
|
): 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")
|
@POST("/_matrix/client/v3/rooms/{roomId}/leave")
|
||||||
suspend fun leaveRoom(
|
suspend fun leaveRoom(
|
||||||
@Header("Authorization") auth: String,
|
@Header("Authorization") auth: String,
|
||||||
|
|
@ -101,6 +107,15 @@ interface MatrixApi {
|
||||||
@Path("roomId") roomId: String
|
@Path("roomId") roomId: String
|
||||||
): JoinedMembersResponse
|
): 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}")
|
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
|
||||||
suspend fun sendToDevice(
|
suspend fun sendToDevice(
|
||||||
@Header("Authorization") auth: String,
|
@Header("Authorization") auth: String,
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,8 @@ data class JoinedRoomSync(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Timeline(
|
data class Timeline(
|
||||||
val events: List<RoomEvent> = emptyList(),
|
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
|
@Serializable
|
||||||
|
|
@ -157,11 +158,20 @@ data class MemberInfo(
|
||||||
@SerialName("avatar_url") val avatarUrl: String? = null
|
@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 ---
|
// --- Local UI models ---
|
||||||
|
|
||||||
data class Room(
|
data class Room(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String
|
val name: String,
|
||||||
|
val isEncrypted: Boolean = false,
|
||||||
|
val canLoadMore: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Message(
|
data class Message(
|
||||||
|
|
@ -169,5 +179,7 @@ data class Message(
|
||||||
val sender: String,
|
val sender: String,
|
||||||
val body: String,
|
val body: String,
|
||||||
val timestamp: Long,
|
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 syncJob: Job? = null
|
||||||
private var api: MatrixApi? = null
|
private var api: MatrixApi? = null
|
||||||
private var cryptoService: CryptoService? = 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
|
* SAS device verification service. Created once; userId/deviceId/cryptoService
|
||||||
|
|
@ -176,6 +179,9 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
encryptedRooms.clear()
|
encryptedRooms.clear()
|
||||||
sharedSessionRooms.clear()
|
sharedSessionRooms.clear()
|
||||||
requestedSessions.clear()
|
requestedSessions.clear()
|
||||||
|
roomEventCache.clear()
|
||||||
|
roomPrevBatches.clear()
|
||||||
|
directRoomTargets.clear()
|
||||||
verificationService.dismiss()
|
verificationService.dismiss()
|
||||||
verificationService.userId = ""
|
verificationService.userId = ""
|
||||||
verificationService.deviceId = ""
|
verificationService.deviceId = ""
|
||||||
|
|
@ -185,30 +191,66 @@ class MatrixSession private constructor(private val appContext: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
|
suspend fun sendMessage(roomId: String, body: String): Result<Unit> {
|
||||||
val token = accessToken ?: return Result.failure(Exception("Not logged in"))
|
return sendTimelineEvent(
|
||||||
val matrixApi = api ?: return Result.failure(Exception("No API client"))
|
roomId = roomId,
|
||||||
return runCatching {
|
eventType = "m.room.message",
|
||||||
val txnId = "${System.currentTimeMillis()}_${txnCounter.incrementAndGet()}"
|
content = buildJsonObject {
|
||||||
val crypto = cryptoService
|
put("msgtype", "m.text")
|
||||||
if (crypto != null && roomId in encryptedRooms) {
|
put("body", body)
|
||||||
// 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 {
|
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("msgtype", "m.text")
|
||||||
put("body", body)
|
put("body", body)
|
||||||
})
|
})
|
||||||
}.toString()
|
put("m.relates_to", buildJsonObject {
|
||||||
val encryptedContent = crypto.encryptRoomEvent(roomId, eventPayload)
|
put("rel_type", "m.replace")
|
||||||
?: throw Exception("Encryption failed — outbound session not established")
|
put("event_id", eventId)
|
||||||
matrixApi.sendEvent("Bearer $token", roomId, "m.room.encrypted", txnId, encryptedContent)
|
})
|
||||||
} else {
|
}
|
||||||
matrixApi.sendMessage("Bearer $token", roomId, txnId, SendMessageRequest(body = body))
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
encryptedRooms.remove(roomId)
|
||||||
sharedSessionRooms.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
|
* 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.
|
* 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) {
|
if (isFirstSync) {
|
||||||
isFirstSync = false
|
isFirstSync = false
|
||||||
|
refreshDirectRoomTargets(token, matrixApi)
|
||||||
fetchMissingRoomNames(token, matrixApi)
|
fetchMissingRoomNames(token, matrixApi)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} 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) {
|
private suspend fun fetchMissingRoomNames(token: String, matrixApi: MatrixApi) {
|
||||||
val current = _syncState.value
|
val current = _syncState.value
|
||||||
val updates = mutableMapOf<String, Room>()
|
val updates = mutableMapOf<String, Room>()
|
||||||
current.rooms.forEach { (roomId, 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 }
|
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 }
|
?: 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)
|
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> {
|
private fun processSyncResponse(response: SyncResponse): List<MissingSession> {
|
||||||
val current = _syncState.value
|
val current = _syncState.value
|
||||||
val rooms = current.rooms.toMutableMap()
|
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 nameEvent = allStateEvents.lastOrNull { it.type == "m.room.name" }
|
||||||
val aliasEvent = allStateEvents.lastOrNull { it.type == "m.room.canonical_alias" }
|
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
|
?: (aliasEvent?.content?.get("alias") as? JsonPrimitive)?.content
|
||||||
?: rooms[roomId]?.name
|
val existingName = rooms[roomId]?.name
|
||||||
|
val resolvedName = if (prefersMemberName(roomId)) {
|
||||||
|
existingName?.takeIf { it.isNotBlank() && it != roomId }
|
||||||
|
?: explicitName
|
||||||
?: roomId
|
?: roomId
|
||||||
|
} else {
|
||||||
rooms[roomId] = Room(id = roomId, name = resolvedName)
|
explicitName
|
||||||
|
?: existingName
|
||||||
|
?: roomId
|
||||||
|
}
|
||||||
|
|
||||||
// Track encryption state
|
// Track encryption state
|
||||||
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
|
val roomIsEncrypted = (roomData.state?.events ?: emptyList())
|
||||||
.any { it.type == "m.room.encryption" }
|
.any { it.type == "m.room.encryption" }
|
||||||
if (roomIsEncrypted) encryptedRooms.add(roomId)
|
if (roomIsEncrypted) encryptedRooms.add(roomId)
|
||||||
|
roomPrevBatches[roomId] = roomData.timeline?.prevBatch ?: roomPrevBatches[roomId]
|
||||||
val newMsgs = roomData.timeline?.events
|
rooms[roomId] = Room(
|
||||||
?.filter { it.type == "m.room.message" || it.type == "m.room.encrypted" }
|
id = roomId,
|
||||||
?.mapNotNull { event ->
|
name = resolvedName,
|
||||||
val body = when (event.type) {
|
isEncrypted = roomId in encryptedRooms,
|
||||||
"m.room.message" ->
|
canLoadMore = roomPrevBatches[roomId] != null
|
||||||
(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()
|
mergeRoomEvents(roomId, roomData.timeline?.events ?: emptyList())
|
||||||
|
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncState.value = SyncState(rooms = rooms, messages = messages)
|
_syncState.value = SyncState(rooms = rooms, messages = messages)
|
||||||
return missingSessions
|
return missingSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryDecrypt(event: RoomEvent, roomId: String, missingSessions: MutableList<MissingSession>): String {
|
private fun mergeRoomEvents(roomId: String, events: List<RoomEvent>) {
|
||||||
val content = event.content ?: return "[encrypted message]"
|
if (events.isEmpty()) return
|
||||||
val result = cryptoService?.decryptRoomEvent(content)
|
val existing = roomEventCache.getOrPut(roomId) { mutableListOf() }
|
||||||
if (result == null) {
|
val knownIds = existing.mapTo(mutableSetOf()) { it.eventId }
|
||||||
val sessionId = (content["session_id"] as? JsonPrimitive)?.content
|
events.forEach { event ->
|
||||||
val senderKey = (content["sender_key"] as? JsonPrimitive)?.content
|
val dedupeKey = event.eventId.ifEmpty { "${event.type}:${event.sender}:${event.timestamp}" }
|
||||||
|
if (dedupeKey !in knownIds) {
|
||||||
|
existing.add(event)
|
||||||
|
knownIds.add(dedupeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (sessionId != null && senderKey != null) {
|
||||||
missingSessions.add(MissingSession(roomId, senderKey, sessionId))
|
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
|
||||||
}
|
}
|
||||||
return result ?: "[encrypted message]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
private var pendingTheirMac: JsonObject? = null
|
private var pendingTheirMac: JsonObject? = null
|
||||||
private var selectedMacMethod: String? = null
|
private var selectedMacMethod: String? = null
|
||||||
private var hasConfirmedLocally: Boolean = false
|
private var hasConfirmedLocally: Boolean = false
|
||||||
|
private var hasVerifiedTheirMac: Boolean = false
|
||||||
|
|
||||||
fun processToDeviceEvent(event: JsonObject) {
|
fun processToDeviceEvent(event: JsonObject) {
|
||||||
val type = (event["type"] as? JsonPrimitive)?.content ?: return
|
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 sasBytes = sas.generateShortCode(info, 6)
|
||||||
val emojis = sasBytes.toSasEmojis()
|
val emojis = sasBytes.toSasEmojis()
|
||||||
hasConfirmedLocally = false
|
hasConfirmedLocally = false
|
||||||
|
hasVerifiedTheirMac = false
|
||||||
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
|
_state.value = VerificationState.ShowingEmojis(txnId, emojis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +274,7 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
|
|
||||||
val doneContent = buildJsonObject { put("transaction_id", txnId) }
|
val doneContent = buildJsonObject { put("transaction_id", txnId) }
|
||||||
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
|
onSend(toUser, toDevice, "m.key.verification.done", "${txnId}_done", doneContent)
|
||||||
|
hasVerifiedTheirMac = true
|
||||||
cleanup()
|
cleanup()
|
||||||
_state.value = VerificationState.Done
|
_state.value = VerificationState.Done
|
||||||
onVerified()
|
onVerified()
|
||||||
|
|
@ -280,6 +283,9 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
|
|
||||||
private fun handleDone(txnId: String) {
|
private fun handleDone(txnId: String) {
|
||||||
if (txnId != currentTxnId) return
|
if (txnId != currentTxnId) return
|
||||||
|
if (!hasVerifiedTheirMac) {
|
||||||
|
return
|
||||||
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
_state.value = VerificationState.Done
|
_state.value = VerificationState.Done
|
||||||
onVerified()
|
onVerified()
|
||||||
|
|
@ -330,6 +336,7 @@ class VerificationService(private val scope: CoroutineScope) {
|
||||||
pendingTheirMac = null
|
pendingTheirMac = null
|
||||||
selectedMacMethod = null
|
selectedMacMethod = null
|
||||||
hasConfirmedLocally = false
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -73,6 +74,7 @@ fun RoomListScreen(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -116,7 +118,11 @@ fun RoomListScreen(
|
||||||
if (actionError != null) {
|
if (actionError != null) {
|
||||||
Text(
|
Text(
|
||||||
text = " ${actionError}",
|
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)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,11 +308,27 @@ private fun RoomRow(room: Room, onClick: () -> Unit, onLongPress: () -> Unit) {
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
||||||
.padding(vertical = 10.dp, horizontal = 4.dp)
|
.padding(vertical = 10.dp, horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "> ${room.name}",
|
text = "> ${room.name}",
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 15.sp, color = MatrixGreen)
|
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(
|
||||||
text = " ${room.id}",
|
text = " ${room.id}",
|
||||||
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
|
style = TextStyle(fontFamily = TerminalFont, fontSize = 10.sp, color = MatrixBorder)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue