diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 72a4dfe0a7..929bcb5dcd 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -1,6 +1,6 @@
name: Pull Request
on:
- pull_request:
+ pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
secrets:
diff --git a/CHANGES.md b/CHANGES.md
index b9fefeaad8..71a53e7e82 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,71 @@
+Changes in Element X v25.04.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514
+* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530
+### 🙌 Improvements
+* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476
+* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507
+* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532
+### 🐛 Bugfixes
+* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482
+* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491
+* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495
+* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499
+* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478
+* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515
+* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536
+* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544
+* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543
+### 🧱 Build
+* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471
+* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490
+* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492
+* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494
+* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501
+* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523
+### Dependency upgrades
+* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487
+* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469
+* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484
+* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493
+* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498
+* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508
+* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516
+* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444
+* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522
+* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527
+* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537
+* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540
+* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548
+### Others
+* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497
+* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496
+* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512
+* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513
+* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521
+* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531
+* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533
+* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545
+* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534
+* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549
+* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432
+* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0
+
Changes in Element X v25.03.4
=============================
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 884ae0ea71..f61fb33868 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -5,8 +5,8 @@
~ Please see LICENSE files in the repository root for full details.
-->
-
+
#FF101317
-
+
#FFFFFFFF
diff --git a/appicon/element/src/main/ic_launcher-playstore.png b/appicon/element/src/main/ic_launcher-playstore.png
index 62af9cf4b2..325bf570f5 100644
Binary files a/appicon/element/src/main/ic_launcher-playstore.png and b/appicon/element/src/main/ic_launcher-playstore.png differ
diff --git a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
index f55c6f23e8..c25675bffc 100644
--- a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
+++ b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt
@@ -10,25 +10,28 @@ package io.element.android.appicon.element
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
@Preview
@Composable
internal fun IconPreview() {
Box {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -36,8 +39,15 @@ internal fun IconPreview() {
@Composable
internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) {
- Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
- Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ Image(
+ modifier = Modifier.matchParentSize(),
+ painter = painterResource(id = R.drawable.ic_launcher_background),
+ contentDescription = null,
+ )
+ Image(
+ painter = painterResource(id = R.mipmap.ic_launcher_foreground),
+ contentDescription = null,
+ )
}
}
@@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
internal fun MonochromeIconPreview() {
Box(
modifier = Modifier
- .size(108.dp)
- .background(Color(0xFF2F3133))
- .clip(shape = RoundedCornerShape(32.dp)),
- contentAlignment = Alignment.Center
+ .background(Color(0xFF2F3133)),
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp
index 793d5ca60d..2ae0da8d0f 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp
deleted file mode 100644
index f051ae3c81..0000000000
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index d1ff05833e..e40370b86f 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 78a93b86f1..8ad6b74901 100644
Binary files a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp
index e8f321ff17..d4e1b90f22 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp
deleted file mode 100644
index 27d9d1db19..0000000000
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index f411c1016c..ac2361f8b0 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 5380a9e861..3cd52b2182 100644
Binary files a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp
index b31de82585..527b23880a 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp
deleted file mode 100644
index 4dbc6db066..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index 5e6654b50c..f8c5c5f218 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index a368522d59..1c98f35c9f 100644
Binary files a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 889388eab6..ed524b893c 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b635d5cbb5..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 9aebd17d21..bb401bcb37 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index af59382417..a6b0547ed0 100644
Binary files a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index 97afc844cb..359e3921a1 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp
deleted file mode 100644
index b5cb68c7bb..0000000000
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp and /dev/null differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index 92e763d12f..f0f9a63324 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index d71ab178fe..36125792fe 100644
Binary files a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/appicon/element/src/release/res/drawable/ic_launcher_background.xml b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
index 6ff3e59543..1cbabacb4f 100644
--- a/appicon/element/src/release/res/drawable/ic_launcher_background.xml
+++ b/appicon/element/src/release/res/drawable/ic_launcher_background.xml
@@ -1,2 +1,10 @@
-
+
+
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index fbecfeb60b..2a7403862e 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
@@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 56f3716ca1..a2016a9171 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
index 49a05c9d36..3c6f30af72 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt
@@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,
diff --git a/fastlane/metadata/android/en-US/changelogs/202504000.txt b/fastlane/metadata/android/en-US/changelogs/202504000.txt
new file mode 100644
index 0000000000..8955ade680
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202504000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
index a3107af4b1..325bf570f5 100644
Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
index 8212cc6c88..6904734f26 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt
@@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
@Composable
override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope()
- val isEnabled = analyticsService.getUserConsent()
- .collectAsState(initial = false)
+ val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) {
when (event) {
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
index 1353d5ab5f..e9319fd7ba 100644
--- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
@@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isTrue()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isTrue()
}
}
@@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
- assertThat(analyticsService.didAskUserConsent().first()).isTrue()
- assertThat(analyticsService.getUserConsent().first()).isFalse()
+ assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
+ assertThat(analyticsService.userConsentFlow.first()).isFalse()
}
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
index 5f629d51eb..29b1525f51 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
@@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
}
val createRoomConfigWithInvites: Flow = combine(
- selectedUserListDataStore.selectedUsers(),
+ selectedUserListDataStore.selectedUsers,
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index fd54b753d9..0025a64df3 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
- val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
+ val isKnockFeatureEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
+ }.collectAsState(initial = false)
val roomAddressValidity = remember {
mutableStateOf(RoomAddressValidity.Unknown)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
index c05ed770f9..ea8be45e6b 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
@@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
- val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
+ val isRoomDirectorySearchEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
+ }.collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
index ad9b18f624..32d5767cc6 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
@@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
- val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
+ val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState> by remember {
mutableStateOf(SearchBarResultState.Initial())
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
index 3be9868c98..a500e3a05f 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
@@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
- private val selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
+ private val _selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
- if (!selectedUsers.value.contains(user)) {
- selectedUsers.tryEmit(selectedUsers.value.plus(user))
+ if (!_selectedUsers.value.contains(user)) {
+ _selectedUsers.tryEmit(_selectedUsers.value.plus(user))
}
}
fun removeUserFromSelection(user: MatrixUser) {
- selectedUsers.tryEmit(selectedUsers.value.minus(user))
+ _selectedUsers.tryEmit(_selectedUsers.value.minus(user))
}
- fun selectedUsers(): Flow> = selectedUsers
+ val selectedUsers = _selectedUsers.asStateFlow()
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index 034701fec4..71a27dcaae 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded()
})
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 0409d73538..744053b976 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
- analyticsService.didAskUserConsent()
+ analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
@@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
}
private suspend fun needsAnalyticsOptIn(): Boolean {
- return analyticsService.didAskUserConsent().first().not()
+ return analyticsService.didAskUserConsentFlow.first().not()
}
private suspend fun shouldAskNotificationPermissions(): Boolean {
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
new file mode 100644
index 0000000000..682970ffe7
--- /dev/null
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+
+interface SeenInvitesStore {
+ /**
+ * Returns a flow of seen room IDs of invitation.
+ */
+ fun seenRoomIds(): Flow>
+
+ /**
+ * Mark the invitation as seen.
+ * Call this when the invitation details are shown to the user.
+ * @param roomId the room ID of the invitation to mark as seen.
+ */
+ suspend fun markAsSeen(roomId: RoomId)
+
+ /**
+ * Mark the invitation as unseen.
+ * Call this when the invitation has been accepted or declined.
+ * @param roomId the room ID of the invitation to mark as unseen.
+ */
+ suspend fun markAsUnSeen(roomId: RoomId)
+
+ /**
+ * Delete the store.
+ */
+ suspend fun clear()
+}
diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts
index 8c00ac3d23..7f052bea09 100644
--- a/features/invite/impl/build.gradle.kts
+++ b/features/invite/impl/build.gradle.kts
@@ -21,6 +21,7 @@ setupAnvil()
dependencies {
api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences)
+ implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
new file mode 100644
index 0000000000..3accc163b0
--- /dev/null
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.impl
+
+import android.content.Context
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStoreFile
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.androidutils.file.safeDelete
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
+
+@SingleIn(SessionScope::class)
+@ContributesBinding(SessionScope::class)
+class DefaultSeenInvitesStore @Inject constructor(
+ @ApplicationContext context: Context,
+ currentSessionIdHolder: CurrentSessionIdHolder,
+ @SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
+ sessionObserver: SessionObserver,
+) : SeenInvitesStore {
+ private val sessionId: SessionId = currentSessionIdHolder.current
+
+ init {
+ sessionObserver.addListener(object : SessionListener {
+ override suspend fun onSessionCreated(userId: String) = Unit
+ override suspend fun onSessionDeleted(userId: String) {
+ if (sessionId.value == userId) {
+ clear()
+ }
+ }
+ })
+ }
+
+ private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
+ context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
+ }
+
+ private val store = PreferenceDataStoreFactory.create(
+ scope = sessionCoroutineScope,
+ migrations = emptyList(),
+ ) {
+ dataStoreFile
+ }
+
+ override fun seenRoomIds(): Flow> =
+ store.data.map { prefs ->
+ prefs[seenInvitesKey]
+ .orEmpty()
+ .map { RoomId(it) }
+ .toSet()
+ }
+
+ override suspend fun markAsSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
+ }
+ }
+
+ override suspend fun markAsUnSeen(roomId: RoomId) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
+ }
+ }
+
+ override suspend fun clear() {
+ dataStoreFile.safeDelete()
+ }
+}
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
index 9f4a7ee848..e02642c32e 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient,
private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
@Composable
override fun present(): AcceptDeclineInviteState {
@@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
)
.onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ seenInvitesStore.markAsUnSeen(roomId)
}
.map { roomId }
}
@@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
+ seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction)
}
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
index 870d3bba09..7feaff091e 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
@@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - declining invite cancel flow`() = runTest {
- val presenter = createAcceptDeclineInvitePresenter()
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
- val presenter = createAcceptDeclineInvitePresenter(client = client)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ client = client,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents()
}
assert(declineInviteFailure).isCalledOnce()
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
}
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
},
ignoreUserResult = ignoreUserSuccess
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
- val presenter = createAcceptDeclineInvitePresenter(client = client)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ client = client,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
}
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.failure(RuntimeException("Failed to join room $roomIdOrAlias"))
}
- val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
+ val presenter = createAcceptDeclineInvitePresenter(
+ joinRoomLambda = joinRoomFailure,
+ seenInvitesStore = seenInvitesStore,
+ )
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList()),
value(JoinedRoom.Trigger.Invite)
)
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
private fun anInviteData(
@@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit)
},
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter(
client = client,
joinRoom = FakeJoinRoom(joinRoomLambda),
notificationCleaner = notificationCleaner,
+ seenInvitesStore = seenInvitesStore,
)
}
}
diff --git a/features/invite/test/build.gradle.kts b/features/invite/test/build.gradle.kts
new file mode 100644
index 0000000000..dc43ba00c3
--- /dev/null
+++ b/features/invite/test/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.invite.test"
+}
+
+dependencies {
+ implementation(libs.coroutines.core)
+ implementation(projects.libraries.matrix.api)
+ api(projects.features.invite.api)
+}
diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt
new file mode 100644
index 0000000000..25db72532e
--- /dev/null
+++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.invite.test
+
+import io.element.android.features.invite.api.SeenInvitesStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class InMemorySeenInvitesStore(
+ initialRoomIds: Set = emptySet(),
+) : SeenInvitesStore {
+ private val roomIds = MutableStateFlow(initialRoomIds)
+
+ override fun seenRoomIds(): Flow> = roomIds
+
+ override suspend fun markAsSeen(roomId: RoomId) {
+ roomIds.value += roomId
+ }
+
+ override suspend fun markAsUnSeen(roomId: RoomId) {
+ roomIds.value -= roomId
+ }
+
+ override suspend fun clear() {
+ roomIds.value = emptySet()
+ }
+}
diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts
index b8684a4c5a..983ae33ce3 100644
--- a/features/joinroom/impl/build.gradle.kts
+++ b/features/joinroom/impl/build.gradle.kts
@@ -43,6 +43,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 722ab53488..9b775c670e 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
@@ -69,6 +71,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val acceptDeclineInvitePresenter: Presenter,
private val buildMeta: BuildMeta,
private val appPreferencesStore: AppPreferencesStore,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
interface Factory {
fun create(
@@ -84,7 +87,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
- val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
+ val roomInfo by remember {
+ matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
+ }.collectAsState(initial = Optional.empty())
val joinAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@@ -150,6 +155,10 @@ class JoinRoomPresenter @AssistedInject constructor(
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ LaunchedEffect(contentState) {
+ contentState.markRoomInviteAsSeen()
+ }
+
fun handleEvents(event: JoinRoomEvents) {
when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@@ -238,6 +247,12 @@ class JoinRoomPresenter @AssistedInject constructor(
forgetRoom.invoke(roomId)
}
}
+
+ private suspend fun ContentState.markRoomInviteAsSeen() {
+ if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
+ seenInvitesStore.markAsSeen(roomId)
+ }
+ }
}
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
index 9a15987f8e..90e22b70bc 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt
@@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
@@ -37,6 +38,7 @@ object JoinRoomModule {
acceptDeclineInvitePresenter: Presenter,
buildMeta: BuildMeta,
appPreferencesStore: AppPreferencesStore,
+ seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(
@@ -60,6 +62,7 @@ object JoinRoomModule {
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
appPreferencesStore = appPreferencesStore,
+ seenInvitesStore = seenInvitesStore,
)
}
}
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index 5cc13f2e65..22f7796a5b 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.ForgetRoom
import io.element.android.features.joinroom.impl.di.KnockRoom
@@ -54,6 +56,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -113,14 +116,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary))
}
}
+ val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter(
- matrixClient = matrixClient
+ matrixClient = matrixClient,
+ seenInvitesStore = seenInvitesStore,
)
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
}
+ // Check that the roomId is stored in the seen invites store
+ assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
}
}
@@ -762,7 +770,8 @@ class JoinRoomPresenterTest {
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
- appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
+ appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
@@ -778,6 +787,7 @@ class JoinRoomPresenterTest {
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
appPreferencesStore = appPreferencesStore,
+ seenInvitesStore = seenInvitesStore,
)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
index b059a88dbb..304f8eb1aa 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt
@@ -11,12 +11,14 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
+import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
+import java.util.Locale
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
) : LocationActions {
override fun share(location: Location, label: String?) {
runCatching {
- val uri = Uri.parse(buildUrl(location, label))
+ val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
}
}
+// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
@VisibleForTesting
internal fun buildUrl(
location: Location,
label: String?,
urlEncoder: (String) -> String = Uri::encode
): String {
- // Ref: https://developer.android.com/guide/components/intents-common#ViewMap
- val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
- return if (label == null) {
- base
- } else {
- "%s (%s)".format(base, urlEncoder(label))
- }
+ // This is needed so the coordinates are formatted with a dot as decimal separator
+ val locale = Locale.ENGLISH
+ return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
index 4a98e22167..5b584a39f8 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import org.junit.Test
import java.net.URLEncoder
+import java.util.Locale
internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
@@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
)
val actual = buildUrl(location, null, ::urlEncoder)
- val expected = "geo:0,0?q=1.234568,123.456789"
+ val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected)
}
@@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
assertThat(actual).isEqualTo(expected)
}
+
+ @Test
+ fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
+ val location = Location(
+ lat = 1.000001,
+ lon = 2.000001,
+ accuracy = 0f
+ )
+ // Set a locale with comma as decimal separator
+ Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
+
+ val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
+ val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
+
+ assertThat(actual).isEqualTo(expected)
+ }
}
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
index 808abf9150..ad77b20e60 100644
--- a/features/lockscreen/impl/build.gradle.kts
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -14,6 +14,10 @@ plugins {
android {
namespace = "io.element.android.features.lockscreen.impl"
+
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
}
setupAnvil()
@@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
@@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
@@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
index 62de1f902d..5c2b1fdd53 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
@@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
@Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
- val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ val isBiometricAllowed by remember {
+ lockScreenStore.isBiometricUnlockAllowed()
+ }.collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
index f9a2cd8767..97dd875b01 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
value = !lockScreenConfig.isPinMandatory && hasPinCode
}
}
- val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ val isBiometricEnabled by remember {
+ lockScreenStore.isBiometricUnlockAllowed()
+ }.collectAsState(initial = false)
var showRemovePinConfirmation by remember {
mutableStateOf(false)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
index 596c6b1b59..820b557423 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
@@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.ui.utils.time.digit
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -60,7 +68,22 @@ fun PinKeypad(
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column(
- modifier = modifier,
+ modifier = modifier.onKeyEvent { event ->
+ if (event.type == KeyEventType.KeyUp) {
+ val digitChar = event.digit
+ if (digitChar != null) {
+ onClick(PinKeypadModel.Number(digitChar))
+ true
+ } else if (event.key == Key.Backspace) {
+ onClick(PinKeypadModel.Back)
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ },
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
) {
@@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace,
- contentDescription = null,
+ contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
new file mode 100644
index 0000000000..e51b007312
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock.keypad
+
+import android.view.KeyEvent
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.hasContentDescription
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.isRoot
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performKeyInput
+import androidx.compose.ui.test.pressKey
+import androidx.compose.ui.test.requestFocus
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class PinKeypadTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on a number emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNode(hasText("1")).performClick()
+ eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
+ }
+
+ @Test
+ fun `clicking on the delete previous character button emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
+ eventsRecorder.assertSingle(PinKeypadModel.Back)
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun `typing using the hardware keyboard emits the expected events`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setPinKeyPad(onClick = eventsRecorder)
+ rule.onNodeWithText("1").requestFocus()
+ rule.onAllNodes(isRoot())[0].performKeyInput {
+ val keys = listOf(
+ Key.A,
+ Key.NumPad1,
+ Key.NumPad2,
+ Key.NumPad3,
+ Key.NumPad4,
+ Key.NumPad5,
+ Key.NumPad6,
+ Key.NumPad7,
+ Key.NumPad8,
+ Key.NumPad9,
+ Key.NumPad0,
+ Key(KeyEvent.KEYCODE_1),
+ Key(KeyEvent.KEYCODE_2),
+ Key(KeyEvent.KEYCODE_3),
+ Key(KeyEvent.KEYCODE_4),
+ Key(KeyEvent.KEYCODE_5),
+ Key(KeyEvent.KEYCODE_6),
+ Key(KeyEvent.KEYCODE_7),
+ Key(KeyEvent.KEYCODE_8),
+ Key(KeyEvent.KEYCODE_9),
+ Key(KeyEvent.KEYCODE_0),
+ Key.Backspace,
+ )
+ for (key in keys) {
+ pressKey(key)
+ }
+ }
+ eventsRecorder.assertList(
+ listOf(
+ // Note that the first key is not a number, but a letter so it's ignored as input
+ // Then we have the numpad keys
+ PinKeypadModel.Number('1'),
+ PinKeypadModel.Number('2'),
+ PinKeypadModel.Number('3'),
+ PinKeypadModel.Number('4'),
+ PinKeypadModel.Number('5'),
+ PinKeypadModel.Number('6'),
+ PinKeypadModel.Number('7'),
+ PinKeypadModel.Number('8'),
+ PinKeypadModel.Number('9'),
+ PinKeypadModel.Number('0'),
+ // And the normal keys from the number row in the keyboard
+ PinKeypadModel.Number('1'),
+ PinKeypadModel.Number('2'),
+ PinKeypadModel.Number('3'),
+ PinKeypadModel.Number('4'),
+ PinKeypadModel.Number('5'),
+ PinKeypadModel.Number('6'),
+ PinKeypadModel.Number('7'),
+ PinKeypadModel.Number('8'),
+ PinKeypadModel.Number('9'),
+ PinKeypadModel.Number('0'),
+ PinKeypadModel.Back,
+ )
+ )
+ }
+
+ private fun AndroidComposeTestRule.setPinKeyPad(
+ onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
+ ) {
+ setContent {
+ PinKeypad(
+ onClick = onClick,
+ maxWidth = 1000.dp,
+ maxHeight = 1000.dp,
+ )
+ }
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
index 3f9c368a1d..3e1fc1853c 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
@@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
defaultAccountProvider
)
- fun flow(): StateFlow {
- return accountProvider.asStateFlow()
- }
+ val flow: StateFlow = accountProvider.asStateFlow()
fun reset() {
accountProvider.tryEmit(defaultAccountProvider)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
index eb5a8fee67..99b029a928 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt
@@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Composable
override fun present(): ConfirmAccountProviderState {
- val accountProvider by accountProviderDataSource.flow().collectAsState()
+ val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState> = remember {
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
index 027036059d..c937cf9d48 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt
@@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
val parser = Json { ignoreUnknownKeys = true }
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
val userId = response.userId ?: error("No user ID in response")
- val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
+ val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
val accessToken = response.accessToken ?: error("No access token in response")
val deviceId = response.deviceId ?: error("No device ID in response")
return ExternalSession(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
index ea60dc66f0..3a12715f6e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt
@@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
- val accountProvider by accountProviderDataSource.flow().collectAsState()
+ val accountProvider by accountProviderDataSource.flow.collectAsState()
fun handleEvents(event: LoginPasswordEvents) {
when (event) {
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
index b0570cee93..e9a8595f3b 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt
@@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
@@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
val sut = AccountProviderDataSource(FakeEnterpriseService(
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
))
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
@@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
- sut.flow().test {
+ sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
sut.userSelection(AccountProvider(url = "https://example.com"))
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
index c26f3a7407..3d1cacd89c 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
}
.collectAsState(initial = BackupUploadState.Unknown)
+ var waitingForALongTime by remember { mutableStateOf(false) }
+ LaunchedEffect(backupUploadState) {
+ if (backupUploadState is BackupUploadState.Waiting) {
+ delay(2_000)
+ waitingForALongTime = true
+ } else {
+ waitingForALongTime = false
+ }
+ }
+
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
@@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
recoveryState = recoveryState,
backupUploadState = backupUploadState,
+ waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
index 2f84c837c6..584d7a0ac9 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
@@ -18,6 +18,7 @@ data class LogoutState(
val doesBackupExistOnServer: Boolean,
val recoveryState: RecoveryState,
val backupUploadState: BackupUploadState,
+ val waitingForALongTime: Boolean,
val logoutAction: AsyncAction,
val eventSink: (LogoutEvents) -> Unit,
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
index 12e83fc713..a55a9afb96 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
@@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider {
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
// Last session no backup
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
+ aLogoutState(
+ isLastDevice = false,
+ backupUploadState = BackupUploadState.Waiting,
+ ),
+ aLogoutState(
+ isLastDevice = false,
+ backupUploadState = BackupUploadState.Waiting,
+ waitingForALongTime = true,
+ ),
)
}
@@ -38,6 +47,7 @@ fun aLogoutState(
doesBackupExistOnServer: Boolean = true,
recoveryState: RecoveryState = RecoveryState.ENABLED,
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
+ waitingForALongTime: Boolean = false,
logoutAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (LogoutEvents) -> Unit = {},
) = LogoutState(
@@ -46,6 +56,7 @@ fun aLogoutState(
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
backupUploadState = backupUploadState,
+ waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction,
eventSink = eventSink,
)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
index f8e5a428c2..321879b009 100644
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
@@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
@Composable
private fun Content(
state: LogoutState,
+ modifier: Modifier = Modifier,
) {
- if (state.backupUploadState is BackupUploadState.Uploading) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 60.dp, start = 20.dp, end = 20.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- LinearProgressIndicator(
- modifier = Modifier.fillMaxWidth(),
- progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
- trackColor = ElementTheme.colors.progressIndicatorTrackColor,
- )
- Text(
- modifier = Modifier.align(Alignment.End),
- text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
- style = ElementTheme.typography.fontBodySmRegular,
- )
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 60.dp, start = 20.dp, end = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ when (state.backupUploadState) {
+ is BackupUploadState.Uploading -> {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
+ )
+ Text(
+ modifier = Modifier.align(Alignment.End),
+ text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ BackupUploadState.Waiting -> {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
+ )
+ if (state.waitingForALongTime) {
+ Text(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ text = stringResource(CommonStrings.common_please_check_internet_connection),
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ }
+ else -> Unit
}
}
}
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
index d054556f87..61a1f1371d 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
@@ -44,6 +44,7 @@ class LogoutPresenterTest {
assertThat(initialState.doesBackupExistOnServer).isTrue()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ assertThat(initialState.waitingForALongTime).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@@ -66,6 +67,34 @@ class LogoutPresenterTest {
}
}
+ @Test
+ fun `present - initial state - waiting a long time`() = runTest {
+ val encryptionService = FakeEncryptionService()
+ encryptionService.givenWaitForBackupUploadSteadyStateFlow(
+ flow {
+ emit(BackupUploadState.Waiting)
+ delay(3_000)
+ }
+ )
+ val presenter = createLogoutPresenter(
+ encryptionService = encryptionService
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.waitingForALongTime).isFalse()
+ assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ val waitingState = awaitItem()
+ assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
+ assertThat(initialState.waitingForALongTime).isFalse()
+ skipItems(1)
+ val waitingALongTimeState = awaitItem()
+ assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
+ assertThat(waitingALongTimeState.waitingForALongTime).isTrue()
+ }
+ }
+
@Test
fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 4541e267fc..b5a1842212 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -183,7 +182,7 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
}
}
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index 5274dcbc22..2ca03e09ea 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
mutableStateOf(ActionListState.Target.None)
}
- val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
+ val isDeveloperModeEnabled by remember {
+ appPreferencesStore.isDeveloperModeEnabledFlow()
+ }.collectAsState(initial = false)
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index e7160d26dc..90bc3d0427 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
- val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
- val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
+ val allowCaption by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation)
+ }.collectAsState(initial = false)
+ val showCaptionCompatibilityWarning by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning)
+ }.collectAsState(initial = false)
var useSendQueue by remember { mutableStateOf(false) }
var preprocessMediaJob by remember { mutableStateOf(null) }
@@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = !useSendQueue,
+ replyParameters = null,
)
}
}
@@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption: String?,
sendActionState: MutableState,
dismissAfterSend: Boolean,
+ replyParameters: ReplyParameters?,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
@@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
).getOrThrow()
}.fold(
onSuccess = {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 73057b1a51..32142734e1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -177,7 +178,9 @@ class MessageComposerPresenter @AssistedInject constructor(
}
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
- val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
+ val sendTypingNotifications by remember {
+ sessionPreferencesStore.isSendTypingNotificationsEnabled()
+ }.collectAsState(initial = true)
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
@@ -397,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
.stateIn(this, SharingStarted.Lazily, emptyList())
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
- val result = suggestionsProcessor.process(
- suggestion = suggestion,
- roomMembersState = roomMembersState,
- roomAliasSuggestions = roomAliasSuggestions,
- currentUserId = currentUserId,
- canSendRoomMention = ::canSendRoomMention,
- )
- suggestions.clear()
- suggestions.addAll(result)
- }
+ val result = suggestionsProcessor.process(
+ suggestion = suggestion,
+ roomMembersState = roomMembersState,
+ roomAliasSuggestions = roomAliasSuggestions,
+ currentUserId = currentUserId,
+ canSendRoomMention = ::canSendRoomMention,
+ )
+ suggestions.clear()
+ suggestions.addAll(result)
+ }
.collect()
}
}
@@ -450,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
- replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
+ with(capturedMode) {
+ replyMessage(
+ body = message.markdown,
+ htmlBody = message.html,
+ intentionalMentions = message.intentionalMentions,
+ replyParameters = ReplyParameters(
+ inReplyToEventId = eventId,
+ enforceThreadReply = inThread,
+ // This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
+ replyWithinThread = false,
+ ),
+ )
+ }
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 4061be78c1..6ac01c990b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
val messageShield: MutableState = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
- val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
- val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
- val isLive by timelineController.isLive().collectAsState(initial = true)
+ val isSendPublicReadReceiptsEnabled by remember {
+ sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
+ }.collectAsState(initial = true)
+ val renderReadReceipts by remember {
+ sessionPreferencesStore.isRenderReadReceiptsEnabled()
+ }.collectAsState(initial = true)
+ val isLive by remember {
+ timelineController.isLive()
+ }.collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) {
when (event) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
index a4a7cff9cb..d496b94f3b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
@@ -7,7 +7,7 @@
package io.element.android.features.messages.impl.timeline.components.event
-import android.text.SpannableString
+import android.text.SpannedString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
@@ -71,7 +71,7 @@ fun TimelineItemTextView(
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val mentionSpanUpdater = LocalMentionSpanUpdater.current
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
- return SpannableString(bodyWithResolvedMentions)
+ return SpannedString.valueOf(bodyWithResolvedMentions)
}
@PreviewsDayNight
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
index 35759ade74..4dfc02a2a1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt
@@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
) : Presenter {
@Composable
override fun present(): TypingNotificationState {
- val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
+ val renderTypingNotifications by remember {
+ sessionPreferencesStore.isRenderTypingNotificationsEnabled()
+ }.collectAsState(initial = true)
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
if (renderTypingNotifications) {
observeRoomTypingMembers()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 63a759bd67..d55ea7c7a0 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send video with caption success scenario`() = runTest {
val sendVideoResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
- lambdaRecorder> { _, _, _, _, _ ->
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
+ any(),
)
onDoneListener.assertions().isCalledOnce()
}
@@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario without media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(
@@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario with media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val onDoneListenerResult = lambdaRecorder {}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index 2f80c52369..a82df0f2aa 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@@ -611,7 +612,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@@ -1110,7 +1111,7 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
- val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean ->
+ val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List, _: Boolean ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List ->
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
index e9ffd00b23..5c3e694062 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
)
private val analyticsService = FakeAnalyticsService()
private val sendVoiceMessageResult =
- lambdaRecorder, ProgressCallback?, Result> { _, _, _, _ ->
+ lambdaRecorder, ProgressCallback?, ReplyParameters?, Result> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
private val matrixRoom = FakeMatrixRoom(
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
index f89d81685f..3d8fc3a0aa 100644
--- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt
@@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
@Composable
override fun present(): MigrationState {
- val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
+ val migrationStoreVersion by remember {
+ migrationStore.applicationMigrationVersion()
+ }.collectAsState(initial = null)
var migrationAction: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) }
// Uncomment this block to run the migration everytime
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
index 00f68e923b..d4da6a76b4 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt
@@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
@Composable
override fun present(): PollHistoryState {
val timeline = room.liveTimeline
- val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
+ val paginationState by timeline.backwardPaginationStatus.collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items)
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index c1f32affa3..8d34d55559 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -74,6 +74,7 @@ dependencies {
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
+ implementation(projects.features.invite.api)
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
@@ -103,6 +104,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.ftue.test)
+ testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.test)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
index 5cefc1ba72..065c6fc553 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
@@ -30,19 +30,18 @@ class AdvancedSettingsPresenter @Inject constructor(
@Composable
override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope()
- val isDeveloperModeEnabled by appPreferencesStore
- .isDeveloperModeEnabledFlow()
- .collectAsState(initial = false)
- val isSharePresenceEnabled by sessionPreferencesStore
- .isSharePresenceEnabled()
- .collectAsState(initial = true)
- val doesCompressMedia by sessionPreferencesStore
- .doesCompressMedia()
- .collectAsState(initial = true)
+ val isDeveloperModeEnabled by remember {
+ appPreferencesStore.isDeveloperModeEnabledFlow()
+ }.collectAsState(initial = false)
+ val isSharePresenceEnabled by remember {
+ sessionPreferencesStore.isSharePresenceEnabled()
+ }.collectAsState(initial = true)
+ val doesCompressMedia by remember {
+ sessionPreferencesStore.doesCompressMedia()
+ }.collectAsState(initial = true)
val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme()
- }
- .collectAsState(initial = Theme.System)
+ }.collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) }
val hideInviteAvatars by remember {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
index 19365e307c..c673276a17 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt
@@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized)
}
- val renderBlockedUsersDetail = featureFlagService
- .isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
- .collectAsState(initial = false)
+ val renderBlockedUsersDetail by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
+ }.collectAsState(initial = false)
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
val ignoredMatrixUser by produceState(
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
- key1 = renderBlockedUsersDetail.value,
+ key1 = renderBlockedUsersDetail,
key2 = ignoredUserIds
) {
value = ignoredUserIds.map {
- if (renderBlockedUsersDetail.value) {
+ if (renderBlockedUsersDetail) {
matrixClient.getProfile(it).getOrNull()
} else {
null
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
index d938f064c1..cc4ae25eba 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
@@ -71,9 +71,10 @@ class DeveloperSettingsPresenter @Inject constructor(
val clearCacheAction = remember {
mutableStateOf>(AsyncAction.Uninitialized)
}
- val customElementCallBaseUrl by appPreferencesStore
- .getCustomElementCallBaseUrlFlow()
- .collectAsState(initial = null)
+ val customElementCallBaseUrl by remember {
+ appPreferencesStore
+ .getCustomElementCallBaseUrlFlow()
+ }.collectAsState(initial = null)
val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
index 317c6e796e..3c1c81e758 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
@@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
- val appNotificationsEnabled = userPushStore
- .getNotificationEnabledForDevice()
- .collectAsState(initial = false)
+ val appNotificationsEnabled by remember {
+ userPushStore.getNotificationEnabledForDevice()
+ }.collectAsState(initial = false)
val matrixSettings: MutableState = remember {
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
@@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
matrixSettings = matrixSettings.value,
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = systemNotificationsEnabled.value,
- appNotificationsEnabled = appNotificationsEnabled.value
+ appNotificationsEnabled = appNotificationsEnabled,
),
changeNotificationSettingAction = changeNotificationSettingAction.value,
currentPushDistributor = currentDistributor,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
index 27763db5f5..8a081c0e41 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
@@ -11,6 +11,7 @@ import android.content.Context
import coil3.SingletonImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
@@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider,
private val ftueService: FtueService,
private val pushService: PushService,
+ private val seenInvitesStore: SeenInvitesStore,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
context.cacheDir.deleteRecursively()
// Clear some settings
ftueService.reset()
+ seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted
diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml
index 118cd7d6a5..0a4cdb7c27 100644
--- a/features/preferences/impl/src/main/res/values-sk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sk/translations.xml
@@ -8,8 +8,11 @@
"Vlastná Element Call základná URL adresa"
"Nastaviť vlastnú základnú URL adresu pre Element Call."
"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."
+ "Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti"
+ "Skryť ukážky médií na časovej osi"
"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"
"Optimalizovať kvalitu médií"
+ "Moderovanie a bezpečnosť"
"Poskytovateľ oznámení Push"
"Vypnite rozšírený textový editor na ručné písanie Markdown."
"Potvrdenia o prečítaní"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
index 778db4a4a8..401477d5fc 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt
@@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.Test
@@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
)
+ val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
+ assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient,
@@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
pushService = pushService,
+ seenInvitesStore = seenInvitesStore,
)
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
@@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
+ assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
}
}
}
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
index 294fc194d5..455cefdb24 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
@@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
screenshotHolder.getFileUri()
)
}
- val crashInfo: String by crashDataStore
- .crashInfo()
- .collectAsState(initial = "")
+ val crashInfo: String by remember {
+ crashDataStore.crashInfo()
+ }.collectAsState(initial = "")
val sendingProgress = remember {
mutableFloatStateOf(0f)
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
index ba883b50a9..b4f45a3bdc 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.rageshake.impl.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
mutableStateOf(rageshake.isAvailable())
}
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
- val isEnabled = rageshakeDataStore
- .isEnabled()
- .collectAsState(initial = false)
+ val isEnabled by remember {
+ rageshakeDataStore.isEnabled()
+ }.collectAsState(initial = false)
- val sensitivity = rageshakeDataStore
- .sensitivity()
- .collectAsState(initial = 0f)
+ val sensitivity by remember {
+ rageshakeDataStore.sensitivity()
+ }.collectAsState(initial = 0f)
fun handleEvents(event: RageshakePreferencesEvents) {
when (event) {
@@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
return RageshakePreferencesState(
isFeatureEnabled = isFeatureAvailable,
- isEnabled = isEnabled.value,
+ isEnabled = isEnabled,
isSupported = isSupported.value,
- sensitivity = sensitivity.value,
+ sensitivity = sensitivity,
eventSink = ::handleEvents
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
index 57d1cc6df0..93c93c345a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt
@@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
+ data class CopyToClipboard(val text: String) : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index df1daf6386..5c6333a290 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
@@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
+import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
+ private val clipboardHelper: ClipboardHelper,
) : Presenter {
@Composable
override fun present(): RoomDetailsState {
@@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
}
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
- val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false)
+ val isKnockRequestsEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
+ }.collectAsState(false)
val knockRequestsCount by produceState(null) {
room.knockRequestsFlow.collect { value = it.size }
}
@@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
+ val snackbarDispatcher = LocalSnackbarDispatcher.current
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
+
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
RoomDetailsEvent.LeaveRoom ->
@@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
}
}
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
+ is RoomDetailsEvent.CopyToClipboard -> {
+ clipboardHelper.copyPlainText(event.text)
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
+ }
}
}
@@ -188,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
+ snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 5502d4e29a..8a0439b15d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -42,6 +43,7 @@ data class RoomDetailsState(
val canShowPinnedMessages: Boolean,
val canShowMediaGallery: Boolean,
val pinnedMessagesCount: Int?,
+ val snackbarMessage: SnackbarMessage?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val canShowSecurityAndPrivacy: Boolean,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index b2db46115c..4304f151a3 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -111,6 +112,7 @@ fun aRoomDetailsState(
canShowPinnedMessages: Boolean = true,
canShowMediaGallery: Boolean = true,
pinnedMessagesCount: Int? = null,
+ snackbarMessage: SnackbarMessage? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
canShowSecurityAndPrivacy: Boolean = true,
@@ -139,11 +141,12 @@ fun aRoomDetailsState(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
+ snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
- eventSink = eventSink
+ eventSink = eventSink,
)
fun aRoomNotificationSettings(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index 33985c44ee..381f94ad26 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
+import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -106,6 +109,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
+ val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
@@ -115,6 +119,7 @@ fun RoomDetailsView(
onActionClick = onActionClick
)
},
+ snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
@@ -135,6 +140,9 @@ fun RoomDetailsView(
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl)
},
+ onSubtitleClick = { subtitle ->
+ state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
+ }
)
}
is RoomDetailsType.Dm -> {
@@ -145,6 +153,9 @@ fun RoomDetailsView(
openAvatarPreview = { name, avatarUrl ->
openAvatarPreview(name, avatarUrl)
},
+ onSubtitleClick = { subtitle ->
+ state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
+ }
)
}
}
@@ -368,6 +379,7 @@ private fun RoomHeaderSection(
roomAlias: RoomAlias?,
heroes: ImmutableList,
openAvatarPreview: (url: String) -> Unit,
+ onSubtitleClick: (String) -> Unit,
) {
Column(
modifier = Modifier
@@ -384,7 +396,11 @@ private fun RoomHeaderSection(
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar)
)
- TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
+ TitleAndSubtitle(
+ title = roomName,
+ subtitle = roomAlias?.value,
+ onSubtitleClick = onSubtitleClick,
+ )
}
}
@@ -394,6 +410,7 @@ private fun DmHeaderSection(
otherMember: RoomMember,
roomName: String,
openAvatarPreview: (name: String, url: String) -> Unit,
+ onSubtitleClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -411,6 +428,7 @@ private fun DmHeaderSection(
TitleAndSubtitle(
title = roomName,
subtitle = otherMember.userId.value,
+ onSubtitleClick = onSubtitleClick,
)
}
}
@@ -419,6 +437,7 @@ private fun DmHeaderSection(
private fun TitleAndSubtitle(
title: String,
subtitle: String?,
+ onSubtitleClick: (String) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
@@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
+ modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
@@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
trailingContent =
- if (pinnedMessagesCount == null) {
- ListItemContent.Custom {
- CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
- }
- } else {
- ListItemContent.Text(pinnedMessagesCount.toString())
- },
+ if (pinnedMessagesCount == null) {
+ ListItemContent.Custom {
+ CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
+ }
+ } else {
+ ListItemContent.Text(pinnedMessagesCount.toString())
+ },
onClick = {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
onPinnedMessagesClick()
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
index 4001eb7edb..e5f63332aa 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
@@ -12,6 +12,7 @@ import dagger.Module
import dagger.Provides
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -25,6 +26,7 @@ object RoomMemberModule {
room: MatrixRoom,
userProfilePresenterFactory: UserProfilePresenterFactory,
encryptionService: EncryptionService,
+ clipboardHelper: ClipboardHelper,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@@ -33,6 +35,7 @@ object RoomMemberModule {
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
index 9a5d456cbe..c5de24c603 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
@@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
+import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
+import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
@@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId,
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
+ private val clipboardHelper: ClipboardHelper,
userProfilePresenterFactory: UserProfilePresenterFactory,
) : Presenter {
interface Factory {
@@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
+ val snackbarDispatcher = LocalSnackbarDispatcher.current
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val roomMember by room.getRoomMemberAsState(roomMemberId)
LaunchedEffect(Unit) {
// Update room member info when opening this screen
@@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
encryptionService.withdrawVerification(roomMemberId)
}
- else -> Unit
+ is UserProfileEvents.CopyToClipboard -> {
+ clipboardHelper.copyPlainText(event.text)
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
+ }
+ else -> userProfileState.eventSink(event)
}
}
@@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userName = roomUserName ?: userProfileState.userName,
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
verificationState = verificationState,
- eventSink = { event ->
- if (event is UserProfileEvents.WithdrawVerification) {
- eventSink(UserProfileEvents.WithdrawVerification)
- } else {
- userProfileState.eventSink(event)
- }
- }
+ snackbarMessage = snackbarMessage,
+ eventSink = ::eventSink
)
}
}
diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml
index 8cf2c4a6a3..16f3d852b1 100644
--- a/features/roomdetails/impl/src/main/res/values-et/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml
@@ -75,6 +75,9 @@
- "%1$d osaleja"
- "%1$d osalejat"
+ "Eemalda"
+ "Uue kutse saamisel on tal võimalik selle jututoaga uuesti liituda."
+ "Kas sa oled kindel, et soovid selle osaleja eemaldada?"
"Eemalda ja sea suhtluskeeld"
"Eemalda kasutaja jututoast"
"Eemalda ja sea suhtluskeeld"
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index 585f61ece5..e9cec9a02a 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -75,6 +75,9 @@
- "%1$d személy"
- "%1$d személy"
+ "Eltávolítás"
+ "Ehhez a szobához is csatlakozhat, ha meghívják."
+ "Biztos, hogy eltávolítja ezt a tagot?"
"Eltávolítás és a tag kitiltása"
"Eltávolítás a szobából"
"Eltávolítás és a tag kitiltása"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 4c470cd1a7..39e2fccaa9 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -76,6 +76,9 @@
- "%1$d osoby"
- "%1$d osôb"
+ "Odstrániť"
+ "V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti."
+ "Ste si istý, že chcete odstrániť tohto člena?"
"Odstrániť a zakázať člena"
"Odstrániť z miestnosti"
"Odstrániť a zakázať člena"
diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
index aace77772d..988c1dfdbc 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -75,6 +75,9 @@
- "%1$d person"
- "%1$d personer"
+ "Ta bort"
+ "Denne kommer kunna gå med i rummet igen om denne bjuds in"
+ "Är du säker på att du vill ta bort den här medlemmen?"
"Ta bort och banna medlem"
"Ta bort från rummet"
"Ta bort och banna medlem"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
index 07d10ede63..6c88ce8c2b 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
@@ -17,6 +17,8 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
+import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -81,6 +83,7 @@ class RoomDetailsPresenterTest {
),
isPinnedMessagesFeatureEnabled: Boolean = true,
encryptionService: FakeEncryptionService = FakeEncryptionService(),
+ clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@@ -92,6 +95,7 @@ class RoomDetailsPresenterTest {
Presenter { aUserProfileState() }
},
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
@@ -106,6 +110,7 @@ class RoomDetailsPresenterTest {
dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService,
+ clipboardHelper = clipboardHelper,
)
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
index 1506f50f42..41ff8706ae 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
@@ -17,6 +17,8 @@ import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
+import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
+import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -350,12 +352,14 @@ class RoomMemberDetailsPresenterTest {
}
},
encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }),
+ clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(
roomMemberId = UserId("@alice:server.org"),
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
+ clipboardHelper = clipboardHelper,
)
}
}
diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts
index 87f683d847..a6f09ff4ed 100644
--- a/features/roomlist/impl/build.gradle.kts
+++ b/features/roomlist/impl/build.gradle.kts
@@ -61,6 +61,9 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
+ testImplementation(projects.features.invite.test)
+ testImplementation(projects.features.logout.test)
+ testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.dateformatter.test)
@@ -72,7 +75,5 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
- testImplementation(projects.features.networkmonitor.test)
- testImplementation(projects.features.logout.test)
testImplementation(projects.tests.testutils)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
index 64348f623c..7435424b15 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContentStateProvider.kt
@@ -11,8 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
+import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentSet
open class RoomListContentStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -29,10 +31,12 @@ internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
+ seenRoomInvites: Set = emptySet(),
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
summaries = summaries,
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index b235553677..082a662c30 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -24,6 +24,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
@@ -50,7 +51,6 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
-import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
@@ -58,6 +58,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -93,6 +94,7 @@ class RoomListPresenter @Inject constructor(
private val logoutPresenter: Presenter,
private val appPreferencesStore: AppPreferencesStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
+ private val seenInvitesStore: SeenInvitesStore,
) : Presenter {
private val encryptionService: EncryptionService = client.encryptionService()
@@ -101,7 +103,7 @@ class RoomListPresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val matrixUser = client.userProfile.collectAsState()
- val isOnline by syncService.isOnline().collectAsState()
+ val isOnline by syncService.isOnline.collectAsState()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
@@ -232,6 +234,7 @@ class RoomListPresenter @Inject constructor(
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
+ val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
return when {
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
@@ -240,7 +243,8 @@ class RoomListPresenter @Inject constructor(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
- summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
+ summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
+ seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
index f8463b2590..307a2d1313 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermiss
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
@Immutable
data class RoomListState(
@@ -66,9 +67,11 @@ sealed interface RoomListContentState {
data class Empty(
val securityBannerState: SecurityBannerState,
) : RoomListContentState
+
data class Rooms(
val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val summaries: ImmutableList,
+ val seenRoomInvites: ImmutableSet,
) : RoomListContentState
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index dd724f9ef8..0118a4fb81 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -46,6 +46,7 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
+import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -245,6 +246,8 @@ private fun RoomsViewList(
RoomSummaryRow(
room = room,
hideInviteAvatars = hideInvitesAvatars,
+ isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
+ state.seenRoomInvites.contains(room.roomId),
onClick = onRoomClick,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
index d19398811a..9f7a67bd00 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
@@ -69,6 +69,7 @@ internal val minHeight = 84.dp
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
hideInviteAvatars: Boolean,
+ isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
@@ -87,7 +88,7 @@ internal fun RoomSummaryRow(
Timber.d("Long click on invite room")
},
) {
- InviteNameAndIndicatorRow(name = room.name)
+ InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
@@ -305,6 +306,7 @@ private fun LastMessageAndIndicatorRow(
@Composable
private fun InviteNameAndIndicatorRow(
name: String?,
+ isInviteSeen: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -321,9 +323,11 @@ private fun InviteNameAndIndicatorRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- UnreadIndicatorAtom(
- color = ElementTheme.colors.unreadIndicator
- )
+ if (!isInviteSeen) {
+ UnreadIndicatorAtom(
+ color = ElementTheme.colors.unreadIndicator
+ )
+ }
}
}
@@ -390,6 +394,8 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider
RoomSummaryRow(
room = data,
hideInviteAvatars = false,
+ // Set isInviteSeen to true for the preview when the room has name "Bob"
+ isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
index 498ad762a3..cb4c48d3f7 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt
@@ -39,12 +39,10 @@ data class RoomListRoomSummary(
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
- isMarkedUnread ||
- displayType == RoomSummaryDisplayType.INVITE
+ isMarkedUnread
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
index 6be9f2be33..bfb2011dd5 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
@@ -177,6 +177,8 @@ private fun RoomListSearchContent(
RoomSummaryRow(
room = room,
hideInviteAvatars = hideInvitesAvatars,
+ // TODO
+ isInviteSeen = false,
onClick = ::onRoomClick,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index 5043b78b10..50e7628d09 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -12,9 +12,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
+import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
@@ -169,10 +171,11 @@ class RoomListPresenterTest {
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
- val presenter = createRoomListPresenter(client = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val presenter = createRoomListPresenter(
+ client = matrixClient,
+ seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
+ )
+ presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
@@ -194,6 +197,7 @@ class RoomListPresenterTest {
timestamp = "0 TimeOrDate true",
)
)
+ assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
cancelAndIgnoreRemainingEvents()
}
}
@@ -680,6 +684,7 @@ class RoomListPresenterTest {
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
+ seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore()
) = RoomListPresenter(
client = client,
syncService = syncService,
@@ -711,6 +716,7 @@ class RoomListPresenterTest {
logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
+ seenInvitesStore = seenInvitesStore,
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
index caa204328b..a7940a203b 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryTest.kt
@@ -65,12 +65,12 @@ class RoomListRoomSummaryTest {
}
@Test
- fun `when display type is invite then isHighlighted and hasNewContent are true`() {
+ fun `when display type is invite then isHighlighted and hasNewContent are false`() {
val sut = createRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
)
- assertThat(sut.isHighlighted).isTrue()
- assertThat(sut.hasNewContent).isTrue()
+ assertThat(sut.isHighlighted).isFalse()
+ assertThat(sut.hasNewContent).isFalse()
}
}
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
index 866170daa3..66cb147158 100644
--- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -116,7 +117,8 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom(
diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
index dd7c24ab35..773954c3e0 100644
--- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
+++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt
@@ -34,7 +34,9 @@ class SignedOutPresenter @AssistedInject constructor(
@Composable
override fun present(): SignedOutState {
- val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList())
+ val sessions by remember {
+ sessionStore.sessionsFlow()
+ }.collectAsState(initial = emptyList())
val signedOutSession by remember {
derivedStateOf { sessions.firstOrNull { it.userId == sessionId } }
}
diff --git a/features/userprofile/api/build.gradle.kts b/features/userprofile/api/build.gradle.kts
index b2c1068556..8bdaa8d77e 100644
--- a/features/userprofile/api/build.gradle.kts
+++ b/features/userprofile/api/build.gradle.kts
@@ -16,5 +16,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
}
diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
index b7b7ba2561..4a5f6bb415 100644
--- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
+++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt
@@ -15,4 +15,5 @@ sealed interface UserProfileEvents {
data object ClearBlockUserError : UserProfileEvents
data object ClearConfirmationDialog : UserProfileEvents
data object WithdrawVerification : UserProfileEvents
+ data class CopyToClipboard(val text: String) : UserProfileEvents
}
diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
index f32033b0a7..b9f08a27f3 100644
--- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
+++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt
@@ -9,6 +9,7 @@ package io.element.android.features.userprofile.api
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -23,6 +24,7 @@ data class UserProfileState(
val isCurrentUser: Boolean,
val dmRoomId: RoomId?,
val canCall: Boolean,
+ val snackbarMessage: SnackbarMessage?,
val eventSink: (UserProfileEvents) -> Unit
) {
enum class ConfirmationDialog {
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
index 4216287a70..c098177529 100644
--- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt
@@ -120,8 +120,9 @@ class UserProfilePresenter @AssistedInject constructor(
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
- // Do nothing for withdrawing verification as it's handled by the RoomMemberDetailsPresenter if needed
- UserProfileEvents.WithdrawVerification -> Unit
+ // Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed
+ UserProfileEvents.WithdrawVerification,
+ is UserProfileEvents.CopyToClipboard -> Unit
}
}
@@ -136,6 +137,7 @@ class UserProfilePresenter @AssistedInject constructor(
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
+ snackbarMessage = null,
eventSink = ::handleEvents
)
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
index 2681d7bfdf..da88c0f508 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRow
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
@@ -48,6 +49,7 @@ fun UserProfileHeaderSection(
userName: String?,
verificationState: UserProfileVerificationState,
openAvatarPreview: (url: String) -> Unit,
+ onUserIdClick: () -> Unit,
withdrawVerificationClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -75,6 +77,7 @@ fun UserProfileHeaderSection(
Spacer(modifier = Modifier.height(6.dp))
}
Text(
+ modifier = Modifier.niceClickable { onUserIdClick() },
text = userId.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
@@ -125,6 +128,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview {
userName = "Alice",
verificationState = UserProfileVerificationState.VERIFIED,
openAvatarPreview = {},
+ onUserIdClick = {},
withdrawVerificationClick = {},
)
}
@@ -138,6 +142,7 @@ internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = Elemen
userName = "Alice",
verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION,
openAvatarPreview = {},
+ onUserIdClick = {},
withdrawVerificationClick = {},
)
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
index be9fad9c94..7a5cc53239 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt
@@ -14,6 +14,7 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -45,6 +46,7 @@ fun aUserProfileState(
isCurrentUser: Boolean = false,
dmRoomId: RoomId? = null,
canCall: Boolean = false,
+ snackbarMessage: SnackbarMessage? = null,
eventSink: (UserProfileEvents) -> Unit = {},
) = UserProfileState(
userId = userId,
@@ -57,5 +59,6 @@ fun aUserProfileState(
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
+ snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
index 0c544cf659..a43478e466 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
@@ -38,6 +38,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
+import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
@@ -55,17 +57,19 @@ fun UserProfileView(
onVerifyClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
+ val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
},
+ snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .verticalScroll(rememberScrollState())
) {
UserProfileHeaderSection(
avatarUrl = state.avatarUrl,
@@ -75,6 +79,9 @@ fun UserProfileView(
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.userName ?: state.userId.value, avatarUrl)
},
+ onUserIdClick = {
+ state.eventSink(UserProfileEvents.CopyToClipboard(state.userId.value))
+ },
withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) },
)
UserProfileMainActionsSection(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 44f6810356..0776c3bc70 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,7 +6,7 @@
android_gradle_plugin = "8.9.1"
kotlin = "2.1.20"
kotlinpoet = "2.1.0"
-ksp = "2.1.20-1.0.32"
+ksp = "2.1.20-2.0.0"
firebaseAppDistribution = "5.1.1"
# AndroidX
@@ -29,7 +29,7 @@ compose_bom = "2025.03.01"
composecompiler = "1.5.15"
# Coroutines
-coroutines = "1.10.1"
+coroutines = "1.10.2"
# Accompanist
accompanist = "0.37.2"
@@ -44,13 +44,13 @@ serialization_json = "1.8.1"
#other
coil = "3.1.0"
showkase = "1.0.3"
-appyx = "1.6.0"
+appyx = "1.7.0"
sqldelight = "2.0.2"
wysiwyg = "2.38.3"
telephoto = "0.15.1"
# Dependency analysis
-dependencyAnalysis = "2.14.0"
+dependencyAnalysis = "2.15.0"
# DI
dagger = "2.56.1"
@@ -149,7 +149,7 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
-test_mockk = "io.mockk:mockk:1.13.17"
+test_mockk = "io.mockk:mockk:1.14.0"
test_konsist = "com.lemonappdev:konsist:0.17.3"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4"
@@ -164,7 +164,7 @@ coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
-compound = { module = "io.element.android:compound-android", version = "25.2.26" }
+compound = { module = "io.element.android:compound-android", version = "25.4.4" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
@@ -174,7 +174,7 @@ jsoup = "org.jsoup:jsoup:1.19.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.3.24"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.4.8"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -188,7 +188,7 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
-maplibre = "org.maplibre.gl:android-sdk:11.8.5"
+maplibre = "org.maplibre.gl:android-sdk:11.8.6"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0"
@@ -239,7 +239,7 @@ anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.8"
ktlint = "org.jlleitschuh.gradle.ktlint:12.2.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
-dependencycheck = "org.owasp.dependencycheck:12.1.0"
+dependencycheck = "org.owasp.dependencycheck:12.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
index 4da9f58f69..51507064eb 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
@@ -14,14 +14,10 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.badgeInfoBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgeInfoContentColor
import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNeutralContentColor
-import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
object MatrixBadgeAtom {
data class MatrixBadgeData(
@@ -42,22 +38,22 @@ object MatrixBadgeAtom {
data: MatrixBadgeData,
) {
val backgroundColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.badgePositiveBackgroundColor
+ Type.Positive -> ElementTheme.colors.bgBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
- Type.Info -> ElementTheme.colors.badgeInfoBackgroundColor
+ Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val textColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.badgePositiveContentColor
+ Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
- Type.Info -> ElementTheme.colors.badgeInfoContentColor
+ Type.Info -> ElementTheme.colors.textBadgeInfo
}
val iconColor = when (data.type) {
- Type.Positive -> ElementTheme.colors.iconSuccessPrimary
+ Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.iconSecondary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
- Type.Info -> ElementTheme.colors.iconInfoPrimary
+ Type.Info -> ElementTheme.colors.textBadgeInfo
}
Badge(
text = data.text,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
index d49e0a41e0..5e2160a01f 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
@@ -25,8 +25,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.badgePositiveBackgroundColor
-import io.element.android.libraries.designsystem.theme.badgePositiveContentColor
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@@ -76,9 +74,9 @@ internal fun BadgePreview() {
Badge(
text = "Trusted",
icon = CompoundIcons.Verified(),
- backgroundColor = ElementTheme.colors.badgePositiveBackgroundColor,
- textColor = ElementTheme.colors.badgePositiveContentColor,
- iconColor = ElementTheme.colors.iconSuccessPrimary,
+ backgroundColor = ElementTheme.colors.bgBadgeAccent,
+ textColor = ElementTheme.colors.textBadgeAccent,
+ iconColor = ElementTheme.colors.textBadgeAccent,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
index 169403104a..3638556da3 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt
@@ -135,7 +135,7 @@ fun ClickableLinkText(
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this
- val spannable = SpannableString(this.text)
+ val spannable = SpannableString.valueOf(this.text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
index c8f71f16cc..3931d8d8d0 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt
@@ -8,7 +8,11 @@
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then(
if (onClick != null) {
@@ -17,3 +21,11 @@ fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then(
Modifier
}
)
+
+fun Modifier.niceClickable(
+ onClick: () -> Unit,
+): Modifier {
+ return clip(RoundedCornerShape(4.dp))
+ .clickable { onClick() }
+ .padding(horizontal = 4.dp)
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
index 36ac7d71bd..79f70cc2f5 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt
@@ -8,7 +8,7 @@
package io.element.android.libraries.designsystem.text
import android.graphics.Typeface
-import android.text.SpannableString
+import android.text.SpannedString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
@@ -26,7 +26,7 @@ import io.element.android.compound.theme.LinkColor
fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
append(this@toAnnotatedString)
- val spannable = SpannableString(this@toAnnotatedString)
+ val spannable = SpannedString.valueOf(this@toAnnotatedString)
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index 503acdb6a4..5576a56519 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -38,77 +38,42 @@ val SemanticColors.placeholderBackground
get() = bgSubtleSecondary
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.messageFromMeBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray400
- Color(0xFFE1E6EC)
- } else {
- // We want DarkDesignTokens.colorGray500
- Color(0xFF323539)
- }
+ get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray500
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.messageFromOtherBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray300
- Color(0xFFF0F2F5)
- } else {
- // We want DarkDesignTokens.colorGray400
- Color(0xFF26282D)
- }
+ get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.progressIndicatorTrackColor
- get() = if (isLight) {
- // We want LightDesignTokens.colorAlphaGray500
- Color(0x33052448)
- } else {
- // We want DarkDesignTokens.colorAlphaGray500
- Color(0x25F4F7FA)
- }
+ get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.iconSuccessPrimaryBackground
- get() = if (isLight) {
- // We want LightDesignTokens.colorGreen300
- Color(0xffe3f7ed)
- } else {
- // We want DarkDesignTokens.colorGreen300
- Color(0xff002513)
- }
+ get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.bgSubtleTertiary
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray100
- Color(0xfffbfcfd)
- } else {
- // We want DarkDesignTokens.colorGray100
- Color(0xff14171b)
- }
+ get() = if (isLight) LightColorTokens.colorGray100 else DarkColorTokens.colorGray100
// Temporary color, which is not in the token right now
val SemanticColors.temporaryColorBgSpecial
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
// This color is not present in Semantic color, so put hard-coded value for now
+@OptIn(CoreColorToken::class)
val SemanticColors.pinDigitBg
- get() = if (isLight) {
- // We want LightDesignTokens.colorGray300
- Color(0xFFF0F2F5)
- } else {
- // We want DarkDesignTokens.colorGray400
- Color(0xFF26282D)
- }
+ get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
+@OptIn(CoreColorToken::class)
val SemanticColors.currentUserMentionPillText
- get() = if (isLight) {
- // We want LightDesignTokens.colorGreen1100
- Color(0xff005c45)
- } else {
- // We want DarkDesignTokens.colorGreen1100
- Color(0xff1fc090)
- }
+ get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
val SemanticColors.currentUserMentionPillBackground
get() = if (isLight) {
@@ -141,14 +106,6 @@ val SemanticColors.highlightedMessageBackgroundColor
// Badge colors
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgePositiveBackgroundColor
- get() = if (isLight) LightColorTokens.colorAlphaGreen300 else DarkColorTokens.colorAlphaGreen300
-
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgePositiveContentColor
- get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
-
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNeutralBackgroundColor
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
@@ -165,14 +122,6 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgeInfoBackgroundColor
- get() = if (isLight) LightColorTokens.colorAlphaBlue300 else DarkColorTokens.colorAlphaBlue300
-
-@OptIn(CoreColorToken::class)
-val SemanticColors.badgeInfoContentColor
- get() = if (isLight) LightColorTokens.colorBlue1100 else DarkColorTokens.colorBlue1100
-
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 915e1584a9..2c77f70d0a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -9,12 +9,14 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData(
val eventId: EventId,
+ val threadId: ThreadId?,
val roomId: RoomId,
// mxc url
val senderAvatarUrl: String?,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index ec94ec0b92..a2a737a019 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -138,7 +139,8 @@ interface MatrixRoom : Closeable {
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendVideo(
@@ -147,7 +149,8 @@ interface MatrixRoom : Closeable {
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendAudio(
@@ -156,6 +159,7 @@ interface MatrixRoom : Closeable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendFile(
@@ -164,8 +168,36 @@ interface MatrixRoom : Closeable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
+ suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result
+
+ /**
+ * Share a location message in the room.
+ *
+ * @param body A human readable textual representation of the location.
+ * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
+ * Respectively: latitude, longitude, and (optional) uncertainty.
+ * @param description Optional description of the location to display to the user.
+ * @param zoomLevel Optional zoom level to display the map at.
+ * @param assetType Optional type of the location asset.
+ * Set to SENDER if sharing own location. Set to PIN if sharing any location.
+ */
+ suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String? = null,
+ zoomLevel: Int? = null,
+ assetType: AssetType? = null,
+ ): Result
+
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
@@ -235,25 +267,6 @@ interface MatrixRoom : Closeable {
*/
suspend fun clearEventCacheStorage(): Result
- /**
- * Share a location message in the room.
- *
- * @param body A human readable textual representation of the location.
- * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`.
- * Respectively: latitude, longitude, and (optional) uncertainty.
- * @param description Optional description of the location to display to the user.
- * @param zoomLevel Optional zoom level to display the map at.
- * @param assetType Optional type of the location asset.
- * Set to SENDER if sharing own location. Set to PIN if sharing any location.
- */
- suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String? = null,
- zoomLevel: Int? = null,
- assetType: AssetType? = null,
- ): Result
-
/**
* Create a poll in the room.
*
@@ -302,13 +315,6 @@ interface MatrixRoom : Closeable {
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result
- suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result
-
/**
* Send a typing notification.
* @param isTyping True if the user is typing, false otherwise.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt
new file mode 100644
index 0000000000..6157989a43
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/ReplyParameters.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.room.message
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+data class ReplyParameters(
+ val inReplyToEventId: EventId,
+ val enforceThreadReply: Boolean,
+ val replyWithinThread: Boolean,
+)
+
+fun replyInThread(eventId: EventId, explicitReply: Boolean = false) = ReplyParameters(
+ inReplyToEventId = eventId,
+ enforceThreadReply = true,
+ replyWithinThread = explicitReply,
+)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
index 21554dbbd6..13cb54b500 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt
@@ -7,7 +7,6 @@
package io.element.android.libraries.matrix.api.sync
-import io.element.android.libraries.core.coroutine.mapState
import kotlinx.coroutines.flow.StateFlow
interface SyncService {
@@ -25,6 +24,6 @@ interface SyncService {
* Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes.
*/
val syncState: StateFlow
-}
-fun SyncService.isOnline(): StateFlow = syncState.mapState { it != SyncState.Offline }
+ val isOnline: StateFlow
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index c74f7b2c00..a940a0981f 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -49,7 +50,10 @@ interface Timeline : AutoCloseable {
val membershipChangeEventReceived: Flow
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result
suspend fun paginate(direction: PaginationDirection): Result
- fun paginationStatus(direction: PaginationDirection): StateFlow
+
+ val backwardPaginationStatus: StateFlow
+ val forwardPaginationStatus: StateFlow
+
val timelineItems: Flow>
suspend fun sendMessage(
@@ -72,7 +76,7 @@ interface Timeline : AutoCloseable {
): Result
suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -85,7 +89,8 @@ interface Timeline : AutoCloseable {
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
suspend fun sendVideo(
@@ -94,18 +99,18 @@ interface Timeline : AutoCloseable {
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
- suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
-
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ): Result
+ replyParameters: ReplyParameters?,
+ ): Result
suspend fun sendFile(
file: File,
@@ -113,15 +118,9 @@ interface Timeline : AutoCloseable {
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result
- suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
-
- suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
-
- suspend fun cancelSend(transactionId: TransactionId): Result =
- redactEvent(transactionId.toEventOrTransactionId(), reason = null)
-
/**
* Share a location message in the room.
*
@@ -141,6 +140,23 @@ interface Timeline : AutoCloseable {
assetType: AssetType? = null,
): Result
+ suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result
+
+ suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
+
+ suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
+
+ suspend fun forwardEvent(eventId: EventId, roomIds: List): Result
+
+ suspend fun cancelSend(transactionId: TransactionId): Result =
+ redactEvent(transactionId.toEventOrTransactionId(), reason = null)
+
/**
* Create a poll in the room.
*
@@ -189,13 +205,6 @@ interface Timeline : AutoCloseable {
*/
suspend fun endPoll(pollStartId: EventId, text: String): Result
- suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result
-
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
/**
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index deca69e1e9..b302784528 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -105,7 +105,7 @@ class RustMatrixClientFactory @Inject constructor(
cachePath = sessionPaths.cacheDirectory.absolutePath,
)
.setSessionDelegate(sessionDelegate)
- .passphrase(passphrase)
+ .sessionPassphrase(passphrase)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
index b253985942..ec220112de 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt
@@ -9,12 +9,9 @@ package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.matrix.rustcomponents.sdk.OidcConfiguration
-import java.io.File
import javax.inject.Inject
-class OidcConfigurationProvider @Inject constructor(
- private val baseDirectory: File,
-) {
+class OidcConfigurationProvider @Inject constructor() {
fun get(): OidcConfiguration = OidcConfiguration(
clientName = "Element",
redirectUri = OidcConfig.REDIRECT_URI,
@@ -29,6 +26,5 @@ class OidcConfigurationProvider @Inject constructor(
staticRegistrations = mapOf(
"https://id.thirdroom.io/realms/thirdroom" to "elementx",
),
- dynamicRegistrationsFile = File(baseDirectory, "oidc/registrations.json").absolutePath,
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 2549fc622a..33af371569 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -324,7 +324,7 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
- .passphrase(passphrase)
+ .sessionPassphrase(passphrase)
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
index 8b21648071..7fdb4a9fdc 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
@@ -36,6 +36,8 @@ class NotificationMapper(
)
NotificationData(
eventId = eventId,
+ // FIXME once the `NotificationItem` in the SDK returns the thread id
+ threadId = null,
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 5b742e83f8..7ed2ad5eb6 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
@@ -497,8 +498,17 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
- return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback)
+ return liveTimeline.sendImage(
+ file = file,
+ thumbnailFile = thumbnailFile,
+ imageInfo = imageInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters
+ )
}
override suspend fun sendVideo(
@@ -508,8 +518,17 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
- return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
+ return liveTimeline.sendVideo(
+ file = file,
+ thumbnailFile = thumbnailFile,
+ videoInfo = videoInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters
+ )
}
override suspend fun sendAudio(
@@ -518,6 +537,7 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return liveTimeline.sendAudio(
file = file,
@@ -525,6 +545,7 @@ class RustMatrixRoom(
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
@@ -534,16 +555,44 @@ class RustMatrixRoom(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return liveTimeline.sendFile(
- file,
- fileInfo,
- caption,
- formattedCaption,
- progressCallback,
+ file = file,
+ fileInfo = fileInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result {
+ return liveTimeline.sendVoiceMessage(
+ file = file,
+ audioInfo = audioInfo,
+ waveform = waveform,
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
+ )
+ }
+
+ override suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String?,
+ zoomLevel: Int?,
+ assetType: AssetType?,
+ ): Result {
+ return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
+ }
+
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result {
return liveTimeline.toggleReaction(emoji, eventOrTransactionId)
}
@@ -631,16 +680,6 @@ class RustMatrixRoom(
}
}
- override suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String?,
- zoomLevel: Int?,
- assetType: AssetType?,
- ): Result {
- return liveTimeline.sendLocation(body, geoUri, description, zoomLevel, assetType)
- }
-
override suspend fun createPoll(
question: String,
answers: List,
@@ -674,15 +713,6 @@ class RustMatrixRoom(
return liveTimeline.endPoll(pollStartId, text)
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result {
- return liveTimeline.sendVoiceMessage(file, audioInfo, waveform, progressCallback)
- }
-
override suspend fun typingNotice(isTyping: Boolean) = withContext(roomDispatcher) {
runCatching {
innerRoom.typingNotice(isTyping)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt
new file mode 100644
index 0000000000..415d493e7d
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/ReplyParameters.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.room.message
+
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
+
+fun ReplyParameters.map() = org.matrix.rustcomponents.sdk.ReplyParameters(
+ eventId = inReplyToEventId.value,
+ enforceThread = enforceThreadReply,
+ replyWithinThread = replyWithinThread,
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
index 3318e1384f..1d8eb7c0ea 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.sync
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineDispatcher
@@ -73,4 +74,6 @@ class RustSyncService(
}
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
+
+ override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 46c2bfebd4..f13b5465fb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.location.toInner
+import io.element.android.libraries.matrix.impl.room.message.map
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
@@ -54,7 +56,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -127,11 +128,11 @@ class RustTimeline(
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
- private val backPaginationStatus = MutableStateFlow(
+ override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
)
- private val forwardPaginationStatus = MutableStateFlow(
+ override val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT)
)
@@ -167,7 +168,7 @@ class RustTimeline(
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.getAndUpdate(update)
+ Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
}
@@ -185,7 +186,7 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
- Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}")
+ Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
@@ -199,21 +200,14 @@ class RustTimeline(
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
if (!isTimelineInitialized.value) return false
return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
+ Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.value.canPaginate
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
}
}
- override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow {
- return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
- Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
- }
- }
-
override val timelineItems: Flow> = combine(
_timelineItems,
- backPaginationStatus,
+ backwardPaginationStatus,
forwardPaginationStatus,
matrixRoom.roomInfoFlow.map { it.creator to it.isDm }.distinctUntilChanged(),
isTimelineInitialized,
@@ -336,7 +330,7 @@ class RustTimeline(
}
override suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -344,7 +338,10 @@ class RustTimeline(
): Result = withContext(dispatcher) {
runCatching {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
- inner.sendReply(msg, eventId.value)
+ inner.sendReply(
+ msg = msg,
+ replyParams = replyParameters.map(),
+ )
}
}
@@ -355,6 +352,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@@ -367,6 +365,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
thumbnailPath = thumbnailFile?.path,
imageInfo = imageInfo.map(),
@@ -382,6 +381,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@@ -394,6 +394,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
thumbnailPath = thumbnailFile?.path,
videoInfo = videoInfo.map(),
@@ -408,6 +409,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
@@ -420,6 +422,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
audioInfo = audioInfo.map(),
progressWatcher = progressCallback?.toProgressWatcher()
@@ -433,6 +436,7 @@ class RustTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
@@ -445,6 +449,7 @@ class RustTimeline(
},
useSendQueue = useSendQueue,
mentions = null,
+ replyParams = replyParameters?.map(),
),
fileInfo = fileInfo.map(),
progressWatcher = progressCallback?.toProgressWatcher(),
@@ -487,6 +492,32 @@ class RustTimeline(
}
}
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result {
+ val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
+ return sendAttachment(listOf(file)) {
+ inner.sendVoiceMessage(
+ params = UploadParameters(
+ filename = file.path,
+ // Maybe allow a caption in the future?
+ caption = null,
+ formattedCaption = null,
+ useSendQueue = useSendQueue,
+ mentions = null,
+ replyParams = replyParameters?.map(),
+ ),
+ audioInfo = audioInfo.map(),
+ waveform = waveform.toMSC3246range(),
+ progressWatcher = progressCallback?.toProgressWatcher(),
+ )
+ }
+ }
+
override suspend fun createPoll(
question: String,
answers: List,
@@ -550,30 +581,6 @@ class RustTimeline(
}
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result {
- val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
- return sendAttachment(listOf(file)) {
- inner.sendVoiceMessage(
- params = UploadParameters(
- filename = file.path,
- // Maybe allow a caption in the future?
- caption = null,
- formattedCaption = null,
- useSendQueue = useSendQueue,
- mentions = null,
- ),
- audioInfo = audioInfo.map(),
- waveform = waveform.toMSC3246range(),
- progressWatcher = progressCallback?.toProgressWatcher(),
- )
- }
- }
-
private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result {
return runCatching {
MediaUploadHandlerImpl(files, handle())
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
index 8df231bbab..d60d88f168 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt
@@ -15,6 +15,7 @@ class VirtualTimelineItemMapper {
return when (virtualTimelineItem) {
is RustVirtualTimelineItem.DateDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong())
RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker
+ RustVirtualTimelineItem.TimelineStart -> VirtualTimelineItem.RoomBeginning
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
index cfc03e50b9..4dded4519d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
@@ -7,8 +7,6 @@
package io.element.android.libraries.matrix.impl.timeline.postprocessor
-import androidx.annotation.VisibleForTesting
-import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -32,55 +30,59 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
return when {
items.isEmpty() -> items
mode == Timeline.Mode.PINNED_EVENTS -> items
- isDm -> processForDM(items, roomCreator)
+ isDm -> processForDM(items, roomCreator, hasMoreToLoadBackwards)
hasMoreToLoadBackwards -> items
else -> processForRoom(items)
}
}
private fun processForRoom(items: List): List {
- val roomBeginningItem = createRoomBeginningItem()
- return listOf(roomBeginningItem) + items
+ // No changes needed, timeline start item is already added by the SDK
+ return items
}
- private fun processForDM(items: List, roomCreator: UserId?): List {
+ private fun processForDM(items: List, roomCreator: UserId?, hasMoreToLoadBackwards: Boolean): List {
+ val roomBeginningItemIndex = if (!hasMoreToLoadBackwards) {
+ items.indexOfFirst { it is MatrixTimelineItem.Virtual && it.virtual is VirtualTimelineItem.RoomBeginning }.takeIf { it >= 0 }
+ } else {
+ null
+ }
+
// Find room creation event.
// This is usually the first MatrixTimelineItem.Event (so index 1, index 0 is a date)
val roomCreationEventIndex = items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
stateEventContent?.content is OtherState.RoomCreate
- }
+ }.takeIf { it >= 0 }
// If the parameter roomCreator is null, the creator is the sender of the RoomCreate Event.
- val roomCreatorUserId = roomCreator ?: (items.getOrNull(roomCreationEventIndex) as? MatrixTimelineItem.Event)?.event?.sender
+ val roomCreatorUserId = roomCreator ?: roomCreationEventIndex?.let {
+ (items.getOrNull(it) as? MatrixTimelineItem.Event)?.event?.sender
+ }
// Find self-join event for the room creator.
// This is usually the second MatrixTimelineItem.Event (so index 2)
val selfUserJoinedEventIndex = roomCreatorUserId?.let { creatorUserId ->
items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? RoomMembershipContent
stateEventContent?.change == MembershipChange.JOINED && stateEventContent.userId == creatorUserId
- }
- } ?: -1
+ }.takeIf { it >= 0 }
+ }
- if (roomCreationEventIndex == -1 && selfUserJoinedEventIndex == -1) {
+ val indicesToRemove = listOfNotNull(
+ roomBeginningItemIndex,
+ roomCreationEventIndex,
+ selfUserJoinedEventIndex,
+ )
+ if (indicesToRemove.isEmpty()) {
+ // Nothing to do, return the list as is
return items
}
+
// Remove items at the indices we found
val newItems = items.toMutableList()
- if (selfUserJoinedEventIndex in newItems.indices) {
- newItems.removeAt(selfUserJoinedEventIndex)
- }
- if (roomCreationEventIndex in newItems.indices) {
- newItems.removeAt(roomCreationEventIndex)
+ indicesToRemove.sortedDescending().forEach { index ->
+ newItems.removeAt(index)
}
return newItems
}
-
- @VisibleForTesting
- fun createRoomBeginningItem(): MatrixTimelineItem.Virtual {
- return MatrixTimelineItem.Virtual(
- uniqueId = UniqueId("RoomBeginning"),
- virtual = VirtualTimelineItem.RoomBeginning
- )
- }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
index b761dd7e3b..7f179ad2f7 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt
@@ -29,7 +29,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
- val isAnalyticsEnabled = analyticsService.getUserConsent().first()
+ val isAnalyticsEnabled = analyticsService.userConsentFlow.first()
val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl,
widgetId = widgetId,
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
index 0164cfeb20..01321fcdf6 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt
@@ -10,12 +10,11 @@ package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.junit.Test
-import java.io.File
class OidcConfigurationProviderTest {
@Test
fun get() {
- val result = OidcConfigurationProvider(File("/base")).get()
+ val result = OidcConfigurationProvider().get()
assertThat(result.redirectUri).isEqualTo(OidcConfig.REDIRECT_URI)
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
index 61df8e6455..e2a9d883ee 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
@@ -48,7 +48,7 @@ class RustMatrixAuthenticationServiceTest {
sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory,
passphraseGenerator = FakePassphraseGenerator(),
- oidcConfigurationProvider = OidcConfigurationProvider(baseDirectory),
+ oidcConfigurationProvider = OidcConfigurationProvider(),
)
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
index 21c62cf3eb..8852734a71 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustClientBuilder.kt
@@ -30,7 +30,7 @@ class FakeRustClientBuilder : ClientBuilder(NoPointer) {
override fun roomDecryptionTrustRequirement(trustRequirement: TrustRequirement) = this
override fun disableSslVerification() = this
override fun homeserverUrl(url: String) = this
- override fun passphrase(passphrase: String?) = this
+ override fun sessionPassphrase(passphrase: String?) = this
override fun proxy(url: String) = this
override fun requestConfig(config: RequestConfig) = this
override fun roomKeyRecipientStrategy(strategy: CollectStrategy) = this
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
index 00e93e0e66..34cb1ac9e1 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
@@ -19,6 +19,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+internal val timelineStartEvent = MatrixTimelineItem.Virtual(
+ uniqueId = UniqueId("timeline_start"),
+ virtual = VirtualTimelineItem.RoomBeginning,
+)
internal val roomCreateEvent = MatrixTimelineItem.Event(
uniqueId = UniqueId("m.room.create"),
event = anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
index 5d72a68a68..bb1e4581a1 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
@@ -50,8 +50,9 @@ class RoomBeginningPostProcessorTest {
}
@Test
- fun `processor removes room creation event and self-join event from DM timeline`() {
+ fun `processor removes timeline start, room creation event and self-join event from DM timeline`() {
val timelineItems = listOf(
+ timelineStartEvent,
roomCreateEvent,
roomCreatorJoinEvent,
)
@@ -98,43 +99,6 @@ class RoomBeginningPostProcessorTest {
assertThat(processedItems).isEqualTo(expected)
}
- @Test
- fun `processor will add beginning of room item if it's not a DM`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false)
- assertThat(processedItems).isEqualTo(
- listOf(processor.createRoomBeginningItem()) + timelineItems
- )
- }
-
- @Test
- fun `processor will not add beginning of room item if it's not a DM but the room has more to load`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
- assertThat(processedItems).isEqualTo(timelineItems)
- }
-
- @Test
- fun `processor will add beginning of room item if it's not a DM, when the parameter roomCreator is null`() {
- val timelineItems = listOf(
- roomCreateEvent,
- roomCreatorJoinEvent,
- )
- val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
- val processedItems = processor.process(timelineItems, isDm = false, roomCreator = null, hasMoreToLoadBackwards = false)
- assertThat(processedItems).isEqualTo(
- listOf(processor.createRoomBeginningItem()) + timelineItems
- )
- }
-
@Test
fun `processor removes items event it's not at the start of the timeline`() {
val timelineItems = listOf(
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
index f99ff0e355..85473d9367 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.notification
+import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -19,6 +20,7 @@ fun aNotificationData(
content: NotificationContent = NotificationContent.MessageLike.RoomEncrypted,
isDirect: Boolean = false,
hasMention: Boolean = false,
+ threadId: ThreadId? = null,
timestamp: Long = A_TIMESTAMP,
senderDisplayName: String? = A_USER_NAME_2,
senderIsNameAmbiguous: Boolean = false,
@@ -26,6 +28,7 @@ fun aNotificationData(
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
+ threadId = threadId,
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = senderDisplayName,
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 7d9bbd1bfc..b7d226d499 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -87,16 +88,16 @@ class FakeMatrixRoom(
private val canRedactOtherResult: (UserId) -> Result = { lambdaError() },
private val canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() },
private val canUserSendMessageResult: (UserId, MessageEventType) -> Result = { _, _ -> lambdaError() },
- private val sendImageResult: (File, File?, ImageInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendImageResult: (File, File?, ImageInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
+ { _, _, _, _, _, _, _ -> lambdaError() },
+ private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
+ { _, _, _, _, _, _, _ -> lambdaError() },
+ private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _, _ -> lambdaError() },
- private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _, _ -> lambdaError() },
- private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result =
+ private val sendVoiceMessageResult: (File, AudioInfo, List, ProgressCallback?, ReplyParameters?) -> Result =
{ _, _, _, _, _ -> lambdaError() },
- private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result =
- { _, _, _, _, _ -> lambdaError() },
- private val sendVoiceMessageResult: (File, AudioInfo, List, ProgressCallback?) -> Result =
- { _, _, _, _ -> lambdaError() },
private val setNameResult: (String) -> Result = { lambdaError() },
private val setTopicResult: (String) -> Result = { lambdaError() },
private val updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() },
@@ -332,7 +333,8 @@ class FakeMatrixRoom(
imageInfo: ImageInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendImageResult(
@@ -342,6 +344,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -351,7 +354,8 @@ class FakeMatrixRoom(
videoInfo: VideoInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendVideoResult(
@@ -361,6 +365,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -369,7 +374,8 @@ class FakeMatrixRoom(
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendAudioResult(
@@ -378,6 +384,7 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
)
}
@@ -386,7 +393,8 @@ class FakeMatrixRoom(
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
- progressCallback: ProgressCallback?
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendFileResult(
@@ -395,6 +403,40 @@ class FakeMatrixRoom(
caption,
formattedCaption,
progressCallback,
+ replyParameters,
+ )
+ }
+
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result = simulateLongTask {
+ simulateSendMediaProgress(progressCallback)
+ sendVoiceMessageResult(
+ file,
+ audioInfo,
+ waveform,
+ progressCallback,
+ replyParameters,
+ )
+ }
+
+ override suspend fun sendLocation(
+ body: String,
+ geoUri: String,
+ description: String?,
+ zoomLevel: Int?,
+ assetType: AssetType?,
+ ): Result = simulateLongTask {
+ return sendLocationResult(
+ body,
+ geoUri,
+ description,
+ zoomLevel,
+ assetType,
)
}
@@ -464,22 +506,6 @@ class FakeMatrixRoom(
return Result.success(Unit)
}
- override suspend fun sendLocation(
- body: String,
- geoUri: String,
- description: String?,
- zoomLevel: Int?,
- assetType: AssetType?,
- ): Result = simulateLongTask {
- return sendLocationResult(
- body,
- geoUri,
- description,
- zoomLevel,
- assetType,
- )
- }
-
override suspend fun createPoll(
question: String,
answers: List,
@@ -524,21 +550,6 @@ class FakeMatrixRoom(
return endPollResult(pollStartId, text)
}
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?
- ): Result = simulateLongTask {
- simulateSendMediaProgress(progressCallback)
- sendVoiceMessageResult(
- file,
- audioInfo,
- waveform,
- progressCallback,
- )
- }
-
override suspend fun typingNotice(isTyping: Boolean): Result {
return typingNoticeResult(isTyping)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
index 9019eea646..5469e5b4aa 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.sync
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +30,8 @@ class FakeSyncService(
override val syncState: StateFlow = syncStateFlow
+ override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline }
+
suspend fun emitSyncState(syncState: SyncState) {
syncStateFlow.emit(syncState)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
index 1c335057b5..859f81b5c3 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -28,19 +29,18 @@ import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import java.io.File
class FakeTimeline(
private val name: String = "FakeTimeline",
override val timelineItems: Flow> = MutableStateFlow(emptyList()),
- private val backwardPaginationStatus: MutableStateFlow = MutableStateFlow(
+ override val backwardPaginationStatus: MutableStateFlow = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = true
)
),
- private val forwardPaginationStatus: MutableStateFlow = MutableStateFlow(
+ override val forwardPaginationStatus: MutableStateFlow = MutableStateFlow(
Timeline.PaginationStatus(
isPaginating = false,
hasMoreToLoad = false
@@ -111,7 +111,7 @@ class FakeTimeline(
)
var replyMessageLambda: (
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
@@ -121,13 +121,13 @@ class FakeTimeline(
}
override suspend fun replyMessage(
- eventId: EventId,
+ replyParameters: ReplyParameters,
body: String,
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean,
): Result = replyMessageLambda(
- eventId,
+ replyParameters,
body,
htmlBody,
intentionalMentions,
@@ -141,7 +141,8 @@ class FakeTimeline(
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -152,13 +153,15 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendImageLambda(
file,
thumbnailFile,
imageInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendVideoLambda: (
@@ -168,7 +171,8 @@ class FakeTimeline(
body: String?,
formattedBody: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -179,13 +183,15 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendVideoLambda(
file,
thumbnailFile,
videoInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendAudioLambda: (
@@ -194,7 +200,8 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -204,12 +211,14 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendAudioLambda(
file,
audioInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
var sendFileLambda: (
@@ -218,7 +227,8 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _, _ ->
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
@@ -228,22 +238,39 @@ class FakeTimeline(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result = sendFileLambda(
file,
fileInfo,
caption,
formattedCaption,
- progressCallback
+ progressCallback,
+ replyParameters,
)
- var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result = { _, _ -> Result.success(Unit) }
- override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = toggleReactionLambda(
- emoji,
- eventOrTransactionId
- )
+ var sendVoiceMessageLambda: (
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ) -> Result = { _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
- var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> Result.success(Unit) }
- override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = forwardEventLambda(eventId, roomIds)
+ override suspend fun sendVoiceMessage(
+ file: File,
+ audioInfo: AudioInfo,
+ waveform: List,
+ progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
+ ): Result = sendVoiceMessageLambda(
+ file,
+ audioInfo,
+ waveform,
+ progressCallback,
+ replyParameters,
+ )
var sendLocationLambda: (
body: String,
@@ -269,6 +296,17 @@ class FakeTimeline(
assetType
)
+ var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result = { _, _ -> Result.success(Unit) }
+
+ override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = toggleReactionLambda(
+ emoji,
+ eventOrTransactionId
+ )
+
+ var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> Result.success(Unit) }
+
+ override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = forwardEventLambda(eventId, roomIds)
+
var createPollLambda: (
question: String,
answers: List,
@@ -338,27 +376,6 @@ class FakeTimeline(
text: String,
): Result = endPollLambda(pollStartId, text)
- var sendVoiceMessageLambda: (
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _, _ ->
- Result.success(FakeMediaUploadHandler())
- }
-
- override suspend fun sendVoiceMessage(
- file: File,
- audioInfo: AudioInfo,
- waveform: List,
- progressCallback: ProgressCallback?,
- ): Result = sendVoiceMessageLambda(
- file,
- audioInfo,
- waveform,
- progressCallback
- )
-
var sendReadReceiptLambda: (
eventId: EventId,
receiptType: ReceiptType,
@@ -377,13 +394,6 @@ class FakeTimeline(
override suspend fun paginate(direction: Timeline.PaginationDirection): Result = paginateLambda(direction)
- override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow {
- return when (direction) {
- Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus
- Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
- }
- }
-
var loadReplyDetailsLambda: (eventId: EventId) -> InReplyTo = {
InReplyTo.NotLoaded(it)
}
diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
index 584539eacc..b68f077df9 100644
--- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
+++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt
@@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.flatMapCatching
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -46,12 +47,14 @@ class MediaSender @Inject constructor(
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
+ replyParameters: ReplyParameters?,
): Result {
return room.sendMedia(
uploadInfo = mediaUploadInfo,
progressCallback = progressCallback,
caption = caption,
- formattedCaption = formattedCaption
+ formattedCaption = formattedCaption,
+ replyParameters = replyParameters,
)
.handleSendResult()
}
@@ -61,7 +64,8 @@ class MediaSender @Inject constructor(
mimeType: String,
caption: String? = null,
formattedCaption: String? = null,
- progressCallback: ProgressCallback? = null
+ progressCallback: ProgressCallback? = null,
+ replyParameters: ReplyParameters? = null,
): Result {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
@@ -76,7 +80,8 @@ class MediaSender @Inject constructor(
uploadInfo = info,
progressCallback = progressCallback,
caption = caption,
- formattedCaption = formattedCaption
+ formattedCaption = formattedCaption,
+ replyParameters = replyParameters,
)
}
.handleSendResult()
@@ -86,7 +91,8 @@ class MediaSender @Inject constructor(
uri: Uri,
mimeType: String,
waveForm: List,
- progressCallback: ProgressCallback? = null
+ progressCallback: ProgressCallback? = null,
+ replyParameters: ReplyParameters? = null,
): Result {
return preProcessor
.process(
@@ -106,7 +112,8 @@ class MediaSender @Inject constructor(
uploadInfo = newInfo,
progressCallback = progressCallback,
caption = null,
- formattedCaption = null
+ formattedCaption = null,
+ replyParameters = replyParameters,
)
}
.handleSendResult()
@@ -128,6 +135,7 @@ class MediaSender @Inject constructor(
progressCallback: ProgressCallback?,
caption: String?,
formattedCaption: String?,
+ replyParameters: ReplyParameters?,
): Result {
val handler = when (uploadInfo) {
is MediaUploadInfo.Image -> {
@@ -137,7 +145,8 @@ class MediaSender @Inject constructor(
imageInfo = uploadInfo.imageInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.Video -> {
@@ -147,7 +156,8 @@ class MediaSender @Inject constructor(
videoInfo = uploadInfo.videoInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.Audio -> {
@@ -156,7 +166,8 @@ class MediaSender @Inject constructor(
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.VoiceMessage -> {
@@ -164,7 +175,8 @@ class MediaSender @Inject constructor(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
waveform = uploadInfo.waveform,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
is MediaUploadInfo.AnyFile -> {
@@ -173,7 +185,8 @@ class MediaSender @Inject constructor(
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
- progressCallback = progressCallback
+ progressCallback = progressCallback,
+ replyParameters = replyParameters,
)
}
}
diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
index 48640da8ea..bd5795af51 100644
--- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
+++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt
@@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@@ -46,7 +47,7 @@ class MediaSenderTest {
@Test
fun `given an attachment when sending it the MatrixRoom will call sendMedia`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -74,8 +75,8 @@ class MediaSenderTest {
@Test
fun `given a failure in the media upload when sending the whole process fails`() = runTest {
val sendImageResult =
- lambdaRecorder> { _, _, _, _, _, _ ->
- Result.failure(Exception())
+ lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
+ Result.failure(Exception())
}
val room = FakeMatrixRoom(
sendImageResult = sendImageResult
@@ -91,7 +92,8 @@ class MediaSenderTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) {
- val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ val sendFileResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
index 3e5e3b7407..e9072e3666 100644
--- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
@@ -13,6 +13,7 @@ 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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -56,13 +57,13 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
// To reset the store: ResetStore()
- val isAlreadyDenied: Boolean by permissionsStore
- .isPermissionDenied(permission)
- .collectAsState(initial = false)
+ val isAlreadyDenied: Boolean by remember {
+ permissionsStore.isPermissionDenied(permission)
+ }.collectAsState(initial = false)
- val isAlreadyAsked: Boolean by permissionsStore
- .isPermissionAsked(permission)
- .collectAsState(initial = false)
+ val isAlreadyAsked: Boolean by remember {
+ permissionsStore.isPermissionAsked(permission)
+ }.collectAsState(initial = false)
var permissionState: PermissionState? = null
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
index 5116e945c5..9e86d054d5 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
@@ -103,6 +103,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
senderId = content.senderId,
roomId = roomId,
eventId = eventId,
+ threadId = threadId,
noisy = isNoisy,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
index aea52c8b78..90ed3a3d05 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt
@@ -14,9 +14,9 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
-import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.message.replyInThread
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.push.api.notifications.NotificationCleaner
@@ -54,7 +54,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
when (intent.action) {
actionIds.smartReply -> if (roomId != null) {
- handleSmartReply(sessionId, roomId, threadId, intent)
+ handleSmartReply(sessionId, roomId, eventId, threadId, intent)
}
actionIds.dismissRoom -> if (roomId != null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
@@ -106,6 +106,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
private fun handleSmartReply(
sessionId: SessionId,
roomId: RoomId,
+ replyToEventId: EventId?,
threadId: ThreadId?,
intent: Intent,
) = appCoroutineScope.launch {
@@ -120,6 +121,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
sendMatrixEvent(
sessionId = sessionId,
roomId = roomId,
+ replyToEventId = replyToEventId,
threadId = threadId,
room = room,
message = message,
@@ -131,6 +133,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
+ replyToEventId: EventId?,
room: MatrixRoom,
message: String,
) {
@@ -158,13 +161,13 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
)
onNotifiableEventReceived.onNotifiableEventReceived(notifiableMessageEvent)
- if (threadId != null) {
+ if (threadId != null && replyToEventId != null) {
room.liveTimeline.replyMessage(
- eventId = threadId.asEventId(),
body = message,
htmlBody = null,
intentionalMentions = emptyList(),
fromNotification = true,
+ replyParameters = replyInThread(replyToEventId),
)
} else {
room.liveTimeline.sendMessage(
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
index f8cf2836b8..d462ebb5f6 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
@@ -197,7 +197,8 @@ class DefaultNotificationCreator @Inject constructor(
addAction(markAsReadActionFactory.create(roomInfo))
// Quick reply
if (!roomInfo.hasSmartReplyError) {
- addAction(quickReplyActionFactory.create(roomInfo, threadId))
+ val latestEventId = events.lastOrNull()?.eventId
+ addAction(quickReplyActionFactory.create(roomInfo, latestEventId, threadId))
}
if (openIntent != null) {
setContentIntent(openIntent)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
index f8efa9b0e7..6a590aeda8 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
@@ -16,6 +16,7 @@ import androidx.core.app.RemoteInput
import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
@@ -33,11 +34,11 @@ class QuickReplyActionFactory @Inject constructor(
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
- fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
+ fun create(roomInfo: RoomEventGroupInfo, eventId: EventId?, threadId: ThreadId?): NotificationCompat.Action? {
if (!NotificationConfig.SHOW_QUICK_REPLY_ACTION) return null
val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId
- val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId)
+ val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, eventId, threadId)
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build()
@@ -63,6 +64,7 @@ class QuickReplyActionFactory @Inject constructor(
private fun buildQuickReplyIntent(
sessionId: SessionId,
roomId: RoomId,
+ eventId: EventId?,
threadId: ThreadId?,
): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
@@ -70,9 +72,8 @@ class QuickReplyActionFactory @Inject constructor(
intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
- threadId?.let {
- intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
- }
+ eventId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, it.value) }
+ threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) }
return PendingIntent.getBroadcast(
context,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
index d75c4f4d59..5a30db6f59 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
@@ -38,10 +38,8 @@ class PushProvidersTest @Inject constructor(
val result = sortedPushProvider.isNotEmpty()
if (result) {
delegate.updateState(
- description = stringProvider.getQuantityString(
- resId = R.plurals.troubleshoot_notifications_test_detect_push_provider_success,
- quantity = sortedPushProvider.size,
- sortedPushProvider.size,
+ description = stringProvider.getString(
+ resId = R.string.troubleshoot_notifications_test_detect_push_provider_success_2,
sortedPushProvider.joinToString { it.name }
),
status = NotificationTroubleshootTestState.Status.Success
diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml
index 5c6c852f14..9860c8bef7 100644
--- a/libraries/push/impl/src/main/res/values/localazy.xml
+++ b/libraries/push/impl/src/main/res/values/localazy.xml
@@ -58,13 +58,14 @@
"No push providers selected."
"Current push provider: %1$s."
"Current push provider"
- "Ensure that the application has at least one push provider."
- "No push providers found."
+ "Ensure that the application supports at least one push provider."
+ "No push provider support found."
- "Found %1$d push provider: %2$s"
- "Found %1$d push providers: %2$s"
- "Detect push providers"
+ "The application was built with support for: %1$s"
+ "Push provider support"
"Check that the application can display notification."
"The notification has not been clicked."
"Cannot display the notification."
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
index 0ce6038d79..9405e19464 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
@@ -14,8 +14,9 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
-import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.IntentionalMention
+import io.element.android.libraries.matrix.api.room.message.ReplyParameters
+import io.element.android.libraries.matrix.api.room.message.replyInThread
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -330,7 +331,8 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply`() = runTest {
val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) }
- val replyMessage = lambdaRecorder, Boolean, Result