Merge branch 'develop' into feature/fga/advanced_settings_moderation_and_safety
2
.github/workflows/pull_request.yml
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
name: Pull Request
|
name: Pull Request
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
|
|
|
||||||
68
CHANGES.md
|
|
@ -1,3 +1,71 @@
|
||||||
|
Changes in Element X v25.04.0
|
||||||
|
=============================
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at 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
|
Changes in Element X v25.03.4
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
~ Please see LICENSE files in the repository root for full details.
|
~ Please see LICENSE files in the repository root for full details.
|
||||||
-->
|
-->
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
|
<!-- Must be equal to DarkColorTokens.colorThemeBg -->
|
||||||
<color name="splashscreen_bg_dark">#FF101317</color>
|
<color name="splashscreen_bg_dark">#FF101317</color>
|
||||||
<!-- Must be equal to LightDesignTokens.colorThemeBg -->
|
<!-- Must be equal to LightColorTokens.colorThemeBg -->
|
||||||
<color name="splashscreen_bg_light">#FFFFFFFF</color>
|
<color name="splashscreen_bg_light">#FFFFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -10,25 +10,28 @@ package io.element.android.appicon.element
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun IconPreview() {
|
internal fun IconPreview() {
|
||||||
Box {
|
Box {
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
Image(
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
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
|
@Composable
|
||||||
internal fun RoundIconPreview() {
|
internal fun RoundIconPreview() {
|
||||||
Box(modifier = Modifier.clip(shape = CircleShape)) {
|
Box(modifier = Modifier.clip(shape = CircleShape)) {
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
Image(
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
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() {
|
internal fun MonochromeIconPreview() {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(108.dp)
|
.background(Color(0xFF2F3133)),
|
||||||
.background(Color(0xFF2F3133))
|
|
||||||
.clip(shape = RoundedCornerShape(32.dp)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
|
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,2 +1,10 @@
|
||||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:src="@mipmap/ic_launcher_background" />
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#010302"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="m0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -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.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
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.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.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
|
|
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
val showSyncSpinner by remember {
|
val showSyncSpinner by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
||||||
|
|
|
||||||
|
|
@ -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.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
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.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
LoadingRoomNodeView(
|
LoadingRoomNodeView(
|
||||||
state = LoadingRoomState.Loading,
|
state = LoadingRoomState.Loading,
|
||||||
hasNetworkConnection = isOnline,
|
hasNetworkConnection = isOnline,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
|
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
|
||||||
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
LoadingRoomNodeView(
|
LoadingRoomNodeView(
|
||||||
state = loadingRoomState,
|
state = loadingRoomState,
|
||||||
hasNetworkConnection = isOnline,
|
hasNetworkConnection = isOnline,
|
||||||
|
|
|
||||||
2
fastlane/metadata/android/en-US/changelogs/202504000.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Main changes in this version: bug fixes and improvements.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AnalyticsPreferencesState {
|
override fun present(): AnalyticsPreferencesState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val isEnabled = analyticsService.getUserConsent()
|
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
|
||||||
.collectAsState(initial = false)
|
|
||||||
|
|
||||||
fun handleEvents(event: AnalyticsOptInEvents) {
|
fun handleEvents(event: AnalyticsOptInEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||||
assertThat(analyticsService.getUserConsent().first()).isTrue()
|
assertThat(analyticsService.userConsentFlow.first()).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||||
assertThat(analyticsService.getUserConsent().first()).isFalse()
|
assertThat(analyticsService.userConsentFlow.first()).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
||||||
selectedUserListDataStore.selectedUsers(),
|
selectedUserListDataStore.selectedUsers,
|
||||||
createRoomConfigFlow,
|
createRoomConfigFlow,
|
||||||
) { selectedUsers, config ->
|
) { selectedUsers, config ->
|
||||||
config.copy(invites = selectedUsers.toImmutableList())
|
config.copy(invites = selectedUsers.toImmutableList())
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||||
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
||||||
val homeserverName = remember { matrixClient.userIdServerName() }
|
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 {
|
val roomAddressValidity = remember {
|
||||||
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val startDmActionState: MutableState<AsyncAction<RoomId>> = 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) {
|
fun handleEvents(event: CreateRoomRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||||
}
|
}
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
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 searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
||||||
mutableStateOf(SearchBarResultState.Initial())
|
mutableStateOf(SearchBarResultState.Initial())
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,22 @@
|
||||||
package io.element.android.features.createroom.impl.userlist
|
package io.element.android.features.createroom.impl.userlist
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class UserListDataStore @Inject constructor() {
|
class UserListDataStore @Inject constructor() {
|
||||||
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
fun selectUser(user: MatrixUser) {
|
fun selectUser(user: MatrixUser) {
|
||||||
if (!selectedUsers.value.contains(user)) {
|
if (!_selectedUsers.value.contains(user)) {
|
||||||
selectedUsers.tryEmit(selectedUsers.value.plus(user))
|
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeUserFromSelection(user: MatrixUser) {
|
fun removeUserFromSelection(user: MatrixUser) {
|
||||||
selectedUsers.tryEmit(selectedUsers.value.minus(user))
|
_selectedUsers.tryEmit(_selectedUsers.value.minus(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers
|
val selectedUsers = _selectedUsers.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
moveToNextStepIfNeeded()
|
moveToNextStepIfNeeded()
|
||||||
})
|
})
|
||||||
|
|
||||||
analyticsService.didAskUserConsent()
|
analyticsService.didAskUserConsentFlow
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { moveToNextStepIfNeeded() }
|
.onEach { moveToNextStepIfNeeded() }
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
|
||||||
.onEach { updateState() }
|
.onEach { updateState() }
|
||||||
.launchIn(sessionCoroutineScope)
|
.launchIn(sessionCoroutineScope)
|
||||||
|
|
||||||
analyticsService.didAskUserConsent()
|
analyticsService.didAskUserConsentFlow
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { updateState() }
|
.onEach { updateState() }
|
||||||
.launchIn(sessionCoroutineScope)
|
.launchIn(sessionCoroutineScope)
|
||||||
|
|
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun needsAnalyticsOptIn(): Boolean {
|
private suspend fun needsAnalyticsOptIn(): Boolean {
|
||||||
return analyticsService.didAskUserConsent().first().not()
|
return analyticsService.didAskUserConsentFlow.first().not()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||||
|
|
|
||||||
|
|
@ -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<Set<RoomId>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ setupAnvil()
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.invite.api)
|
api(projects.features.invite.api)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
@ -35,6 +36,7 @@ dependencies {
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
|
|
|
||||||
|
|
@ -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<Set<RoomId>> =
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
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.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||||
|
|
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
private val joinRoom: JoinRoom,
|
private val joinRoom: JoinRoom,
|
||||||
private val notificationCleaner: NotificationCleaner,
|
private val notificationCleaner: NotificationCleaner,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<AcceptDeclineInviteState> {
|
) : Presenter<AcceptDeclineInviteState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AcceptDeclineInviteState {
|
override fun present(): AcceptDeclineInviteState {
|
||||||
|
|
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||||
|
seenInvitesStore.markAsUnSeen(roomId)
|
||||||
}
|
}
|
||||||
.map { roomId }
|
.map { roomId }
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
client.ignoreUser(inviteData.senderId).getOrThrow()
|
client.ignoreUser(inviteData.senderId).getOrThrow()
|
||||||
}
|
}
|
||||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
||||||
|
seenInvitesStore.markAsUnSeen(inviteData.roomId)
|
||||||
inviteData.roomId
|
inviteData.roomId
|
||||||
}.runCatchingUpdatingState(declinedAction)
|
}.runCatchingUpdatingState(declinedAction)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
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.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||||
import io.element.android.features.invite.api.response.InviteData
|
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.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.UserId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
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
|
||||||
|
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_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_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.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - declining invite cancel flow`() = runTest {
|
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 {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
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
|
@Test
|
||||||
|
|
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
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 {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
cancelAndConsumeRemainingEvents()
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
assert(declineInviteFailure).isCalledOnce()
|
assert(declineInviteFailure).isCalledOnce()
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
},
|
},
|
||||||
ignoreUserResult = ignoreUserSuccess
|
ignoreUserResult = ignoreUserSuccess
|
||||||
)
|
)
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
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 {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
}
|
}
|
||||||
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
||||||
}
|
}
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||||
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
|
Result.failure<Unit>(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 {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
value(emptyList<String>()),
|
value(emptyList<String>()),
|
||||||
value(JoinedRoom.Trigger.Invite)
|
value(JoinedRoom.Trigger.Invite)
|
||||||
)
|
)
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
joinRoomLambda = joinRoomSuccess,
|
joinRoomLambda = joinRoomSuccess,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.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(
|
private fun anInviteData(
|
||||||
|
|
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
},
|
},
|
||||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||||
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): AcceptDeclineInvitePresenter {
|
): AcceptDeclineInvitePresenter {
|
||||||
return AcceptDeclineInvitePresenter(
|
return AcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
joinRoom = FakeJoinRoom(joinRoomLambda),
|
joinRoom = FakeJoinRoom(joinRoomLambda),
|
||||||
notificationCleaner = notificationCleaner,
|
notificationCleaner = notificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
features/invite/test/build.gradle.kts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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<RoomId> = emptySet(),
|
||||||
|
) : SeenInvitesStore {
|
||||||
|
private val roomIds = MutableStateFlow(initialRoomIds)
|
||||||
|
|
||||||
|
override fun seenRoomIds(): Flow<Set<RoomId>> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
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.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.InviteData
|
import io.element.android.features.invite.api.response.InviteData
|
||||||
|
|
@ -69,6 +71,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<JoinRoomState> {
|
) : Presenter<JoinRoomState> {
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
|
|
@ -84,7 +87,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
override fun present(): JoinRoomState {
|
override fun present(): JoinRoomState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var retryCount by remember { mutableIntStateOf(0) }
|
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<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
@ -150,6 +155,10 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||||
|
|
||||||
|
LaunchedEffect(contentState) {
|
||||||
|
contentState.markRoomInviteAsSeen()
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: JoinRoomEvents) {
|
fun handleEvents(event: JoinRoomEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
||||||
|
|
@ -238,6 +247,12 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
forgetRoom.invoke(roomId)
|
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 {
|
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
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.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
||||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
|
|
@ -37,6 +38,7 @@ object JoinRoomModule {
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
buildMeta: BuildMeta,
|
buildMeta: BuildMeta,
|
||||||
appPreferencesStore: AppPreferencesStore,
|
appPreferencesStore: AppPreferencesStore,
|
||||||
|
seenInvitesStore: SeenInvitesStore,
|
||||||
): JoinRoomPresenter.Factory {
|
): JoinRoomPresenter.Factory {
|
||||||
return object : JoinRoomPresenter.Factory {
|
return object : JoinRoomPresenter.Factory {
|
||||||
override fun create(
|
override fun create(
|
||||||
|
|
@ -60,6 +62,7 @@ object JoinRoomModule {
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
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.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
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.CancelKnockRoom
|
||||||
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
||||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
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.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -113,14 +116,19 @@ class JoinRoomPresenterTest {
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomSummary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore()
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
matrixClient = matrixClient
|
matrixClient = matrixClient,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
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(),
|
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): JoinRoomPresenter {
|
): JoinRoomPresenter {
|
||||||
return JoinRoomPresenter(
|
return JoinRoomPresenter(
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
|
|
@ -778,6 +787,7 @@ class JoinRoomPresenterTest {
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
|
|
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
|
||||||
) : LocationActions {
|
) : LocationActions {
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = Uri.parse(buildUrl(location, label))
|
val uri = buildUrl(location, label).toUri()
|
||||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||||
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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
|
@VisibleForTesting
|
||||||
internal fun buildUrl(
|
internal fun buildUrl(
|
||||||
location: Location,
|
location: Location,
|
||||||
label: String?,
|
label: String?,
|
||||||
urlEncoder: (String) -> String = Uri::encode
|
urlEncoder: (String) -> String = Uri::encode
|
||||||
): String {
|
): String {
|
||||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
// This is needed so the coordinates are formatted with a dot as decimal separator
|
||||||
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
|
val locale = Locale.ENGLISH
|
||||||
return if (label == null) {
|
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
|
||||||
base
|
|
||||||
} else {
|
|
||||||
"%s (%s)".format(base, urlEncoder(label))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
internal class AndroidLocationActionsTest {
|
internal class AndroidLocationActionsTest {
|
||||||
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
// 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 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)
|
assertThat(actual).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
|
||||||
|
|
||||||
assertThat(actual).isEqualTo(expected)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.lockscreen.impl"
|
namespace = "io.element.android.features.lockscreen.impl"
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
@ -30,6 +34,8 @@ dependencies {
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.cryptography.api)
|
implementation(projects.libraries.cryptography.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.testtags)
|
||||||
|
implementation(projects.libraries.uiUtils)
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
|
|
@ -42,6 +48,9 @@ dependencies {
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
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.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.libraries.cryptography.test)
|
testImplementation(projects.libraries.cryptography.test)
|
||||||
|
|
@ -50,4 +59,5 @@ dependencies {
|
||||||
testImplementation(projects.libraries.sessionStorage.test)
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.features.logout.test)
|
testImplementation(projects.features.logout.test)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
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 lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||||
val isAvailable by remember(lifecycleState) {
|
val isAvailable by remember(lifecycleState) {
|
||||||
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
|
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||||
value = !lockScreenConfig.isPinMandatory && hasPinCode
|
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 {
|
var showRemovePinConfirmation by remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.Dp
|
||||||
import androidx.compose.ui.unit.coerceIn
|
import androidx.compose.ui.unit.coerceIn
|
||||||
import androidx.compose.ui.unit.dp
|
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.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toSp
|
import io.element.android.libraries.designsystem.text.toSp
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
@ -60,7 +68,22 @@ fun PinKeypad(
|
||||||
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
|
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
|
||||||
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
|
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
|
||||||
Column(
|
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,
|
verticalArrangement = verticalArrangement,
|
||||||
horizontalAlignment = horizontalAlignment,
|
horizontalAlignment = horizontalAlignment,
|
||||||
) {
|
) {
|
||||||
|
|
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(CommonStrings.a11y_delete),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on a number emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||||
|
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<PinKeypadModel>()
|
||||||
|
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<PinKeypadModel>()
|
||||||
|
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
|
||||||
|
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
PinKeypad(
|
||||||
|
onClick = onClick,
|
||||||
|
maxWidth = 1000.dp,
|
||||||
|
maxHeight = 1000.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
|
||||||
defaultAccountProvider
|
defaultAccountProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
fun flow(): StateFlow<AccountProvider> {
|
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
|
||||||
return accountProvider.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
accountProvider.tryEmit(defaultAccountProvider)
|
accountProvider.tryEmit(defaultAccountProvider)
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ConfirmAccountProviderState {
|
override fun present(): ConfirmAccountProviderState {
|
||||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {
|
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
|
||||||
val parser = Json { ignoreUnknownKeys = true }
|
val parser = Json { ignoreUnknownKeys = true }
|
||||||
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
|
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
|
||||||
val userId = response.userId ?: error("No user ID in response")
|
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 accessToken = response.accessToken ?: error("No access token in response")
|
||||||
val deviceId = response.deviceId ?: error("No device ID in response")
|
val deviceId = response.deviceId ?: error("No device ID in response")
|
||||||
return ExternalSession(
|
return ExternalSession(
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
|
||||||
val formState = rememberSaveable {
|
val formState = rememberSaveable {
|
||||||
mutableStateOf(LoginFormState.Default)
|
mutableStateOf(LoginFormState.Default)
|
||||||
}
|
}
|
||||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||||
|
|
||||||
fun handleEvents(event: LoginPasswordEvents) {
|
fun handleEvents(event: LoginPasswordEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||||
sut.flow().test {
|
sut.flow.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState).isEqualTo(
|
assertThat(initialState).isEqualTo(
|
||||||
AccountProvider(
|
AccountProvider(
|
||||||
|
|
@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
|
||||||
val sut = AccountProviderDataSource(FakeEnterpriseService(
|
val sut = AccountProviderDataSource(FakeEnterpriseService(
|
||||||
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
|
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
|
||||||
))
|
))
|
||||||
sut.flow().test {
|
sut.flow.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState).isEqualTo(
|
assertThat(initialState).isEqualTo(
|
||||||
AccountProvider(
|
AccountProvider(
|
||||||
|
|
@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - user change and reset`() = runTest {
|
fun `present - user change and reset`() = runTest {
|
||||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||||
sut.flow().test {
|
sut.flow.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
|
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
|
||||||
sut.userSelection(AccountProvider(url = "https://example.com"))
|
sut.userSelection(AccountProvider(url = "https://example.com"))
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
.collectAsState(initial = BackupUploadState.Unknown)
|
.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 isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||||
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||||
|
|
@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
|
||||||
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
|
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
|
||||||
recoveryState = recoveryState,
|
recoveryState = recoveryState,
|
||||||
backupUploadState = backupUploadState,
|
backupUploadState = backupUploadState,
|
||||||
|
waitingForALongTime = waitingForALongTime,
|
||||||
logoutAction = logoutAction.value,
|
logoutAction = logoutAction.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ data class LogoutState(
|
||||||
val doesBackupExistOnServer: Boolean,
|
val doesBackupExistOnServer: Boolean,
|
||||||
val recoveryState: RecoveryState,
|
val recoveryState: RecoveryState,
|
||||||
val backupUploadState: BackupUploadState,
|
val backupUploadState: BackupUploadState,
|
||||||
|
val waitingForALongTime: Boolean,
|
||||||
val logoutAction: AsyncAction<Unit>,
|
val logoutAction: AsyncAction<Unit>,
|
||||||
val eventSink: (LogoutEvents) -> Unit,
|
val eventSink: (LogoutEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
|
||||||
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
|
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
|
||||||
// Last session no backup
|
// Last session no backup
|
||||||
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
|
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,
|
doesBackupExistOnServer: Boolean = true,
|
||||||
recoveryState: RecoveryState = RecoveryState.ENABLED,
|
recoveryState: RecoveryState = RecoveryState.ENABLED,
|
||||||
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
|
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
|
||||||
|
waitingForALongTime: Boolean = false,
|
||||||
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (LogoutEvents) -> Unit = {},
|
eventSink: (LogoutEvents) -> Unit = {},
|
||||||
) = LogoutState(
|
) = LogoutState(
|
||||||
|
|
@ -46,6 +56,7 @@ fun aLogoutState(
|
||||||
doesBackupExistOnServer = doesBackupExistOnServer,
|
doesBackupExistOnServer = doesBackupExistOnServer,
|
||||||
recoveryState = recoveryState,
|
recoveryState = recoveryState,
|
||||||
backupUploadState = backupUploadState,
|
backupUploadState = backupUploadState,
|
||||||
|
waitingForALongTime = waitingForALongTime,
|
||||||
logoutAction = logoutAction,
|
logoutAction = logoutAction,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
|
||||||
@Composable
|
@Composable
|
||||||
private fun Content(
|
private fun Content(
|
||||||
state: LogoutState,
|
state: LogoutState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (state.backupUploadState is BackupUploadState.Uploading) {
|
Column(
|
||||||
Column(
|
modifier = modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
) {
|
||||||
) {
|
when (state.backupUploadState) {
|
||||||
LinearProgressIndicator(
|
is BackupUploadState.Uploading -> {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
LinearProgressIndicator(
|
||||||
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||||
)
|
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||||
Text(
|
)
|
||||||
modifier = Modifier.align(Alignment.End),
|
Text(
|
||||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
modifier = Modifier.align(Alignment.End),
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class LogoutPresenterTest {
|
||||||
assertThat(initialState.doesBackupExistOnServer).isTrue()
|
assertThat(initialState.doesBackupExistOnServer).isTrue()
|
||||||
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
|
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
|
||||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||||
|
assertThat(initialState.waitingForALongTime).isFalse()
|
||||||
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
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
|
@Test
|
||||||
fun `present - initial state - backing up`() = runTest {
|
fun `present - initial state - backing up`() = runTest {
|
||||||
val encryptionService = FakeEncryptionService()
|
val encryptionService = FakeEncryptionService()
|
||||||
|
|
|
||||||
|
|
@ -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.canRedactOwn
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
|
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.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.api.timeline.item.event.EventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
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
|
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()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
mutableStateOf(ActionListState.Target.None)
|
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 isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
|
||||||
val pinnedEventIds by remember {
|
val pinnedEventIds by remember {
|
||||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||||
|
|
|
||||||
|
|
@ -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.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
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.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.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.api.allFiles
|
import io.element.android.libraries.mediaupload.api.allFiles
|
||||||
|
|
@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
|
val allowCaption by remember {
|
||||||
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
|
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 useSendQueue by remember { mutableStateOf(false) }
|
||||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
caption = caption,
|
caption = caption,
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
dismissAfterSend = !useSendQueue,
|
dismissAfterSend = !useSendQueue,
|
||||||
|
replyParameters = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
caption: String?,
|
caption: String?,
|
||||||
sendActionState: MutableState<SendActionState>,
|
sendActionState: MutableState<SendActionState>,
|
||||||
dismissAfterSend: Boolean,
|
dismissAfterSend: Boolean,
|
||||||
|
replyParameters: ReplyParameters?,
|
||||||
) = runCatching {
|
) = runCatching {
|
||||||
val context = coroutineContext
|
val context = coroutineContext
|
||||||
val progressCallback = object : ProgressCallback {
|
val progressCallback = object : ProgressCallback {
|
||||||
|
|
@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
mediaUploadInfo = mediaUploadInfo,
|
mediaUploadInfo = mediaUploadInfo,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
progressCallback = progressCallback
|
progressCallback = progressCallback,
|
||||||
|
replyParameters = replyParameters,
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
|
|
|
||||||
|
|
@ -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.ComposerDraft
|
||||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
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.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.TimelineException
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
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) }
|
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) {
|
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||||
if (cameraPermissionState.permissionGranted) {
|
if (cameraPermissionState.permissionGranted) {
|
||||||
|
|
@ -397,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||||
|
|
||||||
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
|
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
|
||||||
val result = suggestionsProcessor.process(
|
val result = suggestionsProcessor.process(
|
||||||
suggestion = suggestion,
|
suggestion = suggestion,
|
||||||
roomMembersState = roomMembersState,
|
roomMembersState = roomMembersState,
|
||||||
roomAliasSuggestions = roomAliasSuggestions,
|
roomAliasSuggestions = roomAliasSuggestions,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
canSendRoomMention = ::canSendRoomMention,
|
canSendRoomMention = ::canSendRoomMention,
|
||||||
)
|
)
|
||||||
suggestions.clear()
|
suggestions.clear()
|
||||||
suggestions.addAll(result)
|
suggestions.addAll(result)
|
||||||
}
|
}
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
is MessageComposerMode.Reply -> {
|
is MessageComposerMode.Reply -> {
|
||||||
timelineController.invokeOnCurrentTimeline {
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
|
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
|
||||||
|
|
||||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
val isSendPublicReadReceiptsEnabled by remember {
|
||||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
|
||||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
}.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) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import android.text.SpannableString
|
import android.text.SpannedString
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
|
@ -71,7 +71,7 @@ fun TimelineItemTextView(
|
||||||
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
||||||
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
||||||
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
|
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
|
||||||
return SpannableString(bodyWithResolvedMentions)
|
return SpannedString.valueOf(bodyWithResolvedMentions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
|
||||||
) : Presenter<TypingNotificationState> {
|
) : Presenter<TypingNotificationState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TypingNotificationState {
|
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) {
|
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
|
||||||
if (renderTypingNotifications) {
|
if (renderTypingNotifications) {
|
||||||
observeRoomTypingMembers()
|
observeRoomTypingMembers()
|
||||||
|
|
|
||||||
|
|
@ -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.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
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.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.A_CAPTION
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||||
|
|
@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send media success scenario`() = runTest {
|
fun `present - send media success scenario`() = runTest {
|
||||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
|
|
@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send media after pre-processing success scenario`() = runTest {
|
fun `present - send media after pre-processing success scenario`() = runTest {
|
||||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
|
|
@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send media before pre-processing success scenario`() = runTest {
|
fun `present - send media before pre-processing success scenario`() = runTest {
|
||||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
|
|
@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - send image with caption success scenario`() = runTest {
|
fun `present - send image with caption success scenario`() = runTest {
|
||||||
val sendImageResult =
|
val sendImageResult =
|
||||||
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||||
|
|
@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
value(A_CAPTION),
|
value(A_CAPTION),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
)
|
)
|
||||||
onDoneListener.assertions().isCalledOnce()
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - send video with caption success scenario`() = runTest {
|
fun `present - send video with caption success scenario`() = runTest {
|
||||||
val sendVideoResult =
|
val sendVideoResult =
|
||||||
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||||
|
|
@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
value(A_CAPTION),
|
value(A_CAPTION),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
)
|
)
|
||||||
onDoneListener.assertions().isCalledOnce()
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - send audio with caption success scenario`() = runTest {
|
fun `present - send audio with caption success scenario`() = runTest {
|
||||||
val sendAudioResult =
|
val sendAudioResult =
|
||||||
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||||
|
|
@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
value(A_CAPTION),
|
value(A_CAPTION),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
)
|
)
|
||||||
onDoneListener.assertions().isCalledOnce()
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - send media failure scenario without media queue`() = runTest {
|
fun `present - send media failure scenario without media queue`() = runTest {
|
||||||
val failure = MediaPreProcessor.Failure(null)
|
val failure = MediaPreProcessor.Failure(null)
|
||||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
|
|
@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - send media failure scenario with media queue`() = runTest {
|
fun `present - send media failure scenario with media queue`() = runTest {
|
||||||
val failure = MediaPreProcessor.Failure(null)
|
val failure = MediaPreProcessor.Failure(null)
|
||||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
val sendFileResult =
|
||||||
|
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
val onDoneListenerResult = lambdaRecorder<Unit> {}
|
val onDoneListenerResult = lambdaRecorder<Unit> {}
|
||||||
|
|
|
||||||
|
|
@ -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.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
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.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.TimelineException
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
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.InReplyTo
|
||||||
|
|
@ -611,7 +612,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - reply message`() = runTest {
|
fun `present - reply message`() = runTest {
|
||||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val timeline = FakeTimeline().apply {
|
val timeline = FakeTimeline().apply {
|
||||||
|
|
@ -1110,7 +1111,7 @@ class MessageComposerPresenterTest {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `present - send messages with intentional mentions`() = runTest {
|
fun `present - send messages with intentional mentions`() = runTest {
|
||||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||||
|
|
|
||||||
|
|
@ -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.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
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.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.media.FakeMediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
|
|
@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
)
|
)
|
||||||
private val analyticsService = FakeAnalyticsService()
|
private val analyticsService = FakeAnalyticsService()
|
||||||
private val sendVoiceMessageResult =
|
private val sendVoiceMessageResult =
|
||||||
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
|
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||||
Result.success(FakeMediaUploadHandler())
|
Result.success(FakeMediaUploadHandler())
|
||||||
}
|
}
|
||||||
private val matrixRoom = FakeMatrixRoom(
|
private val matrixRoom = FakeMatrixRoom(
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MigrationState {
|
override fun present(): MigrationState {
|
||||||
val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
|
val migrationStoreVersion by remember {
|
||||||
|
migrationStore.applicationMigrationVersion()
|
||||||
|
}.collectAsState(initial = null)
|
||||||
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
|
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
|
|
||||||
// Uncomment this block to run the migration everytime
|
// Uncomment this block to run the migration everytime
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): PollHistoryState {
|
override fun present(): PollHistoryState {
|
||||||
val timeline = room.liveTimeline
|
val timeline = room.liveTimeline
|
||||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
val paginationState by timeline.backwardPaginationStatus.collectAsState()
|
||||||
val pollHistoryItemsFlow = remember {
|
val pollHistoryItemsFlow = remember {
|
||||||
timeline.timelineItems.map { items ->
|
timeline.timelineItems.map { items ->
|
||||||
pollHistoryItemFactory.create(items)
|
pollHistoryItemFactory.create(items)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ dependencies {
|
||||||
implementation(projects.features.licenses.api)
|
implementation(projects.features.licenses.api)
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.features.deactivation.api)
|
implementation(projects.features.deactivation.api)
|
||||||
|
implementation(projects.features.invite.api)
|
||||||
implementation(projects.features.roomlist.api)
|
implementation(projects.features.roomlist.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.services.analytics.compose)
|
implementation(projects.services.analytics.compose)
|
||||||
|
|
@ -103,6 +104,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.libraries.pushstore.test)
|
testImplementation(projects.libraries.pushstore.test)
|
||||||
testImplementation(projects.features.ftue.test)
|
testImplementation(projects.features.ftue.test)
|
||||||
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.features.rageshake.test)
|
testImplementation(projects.features.rageshake.test)
|
||||||
testImplementation(projects.features.rageshake.impl)
|
testImplementation(projects.features.rageshake.impl)
|
||||||
testImplementation(projects.features.logout.test)
|
testImplementation(projects.features.logout.test)
|
||||||
|
|
|
||||||
|
|
@ -30,19 +30,18 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AdvancedSettingsState {
|
override fun present(): AdvancedSettingsState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val isDeveloperModeEnabled by appPreferencesStore
|
val isDeveloperModeEnabled by remember {
|
||||||
.isDeveloperModeEnabledFlow()
|
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||||
.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
val isSharePresenceEnabled by sessionPreferencesStore
|
val isSharePresenceEnabled by remember {
|
||||||
.isSharePresenceEnabled()
|
sessionPreferencesStore.isSharePresenceEnabled()
|
||||||
.collectAsState(initial = true)
|
}.collectAsState(initial = true)
|
||||||
val doesCompressMedia by sessionPreferencesStore
|
val doesCompressMedia by remember {
|
||||||
.doesCompressMedia()
|
sessionPreferencesStore.doesCompressMedia()
|
||||||
.collectAsState(initial = true)
|
}.collectAsState(initial = true)
|
||||||
val theme by remember {
|
val theme by remember {
|
||||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||||
}
|
}.collectAsState(initial = Theme.System)
|
||||||
.collectAsState(initial = Theme.System)
|
|
||||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val hideInviteAvatars by remember {
|
val hideInviteAvatars by remember {
|
||||||
|
|
|
||||||
|
|
@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
|
||||||
mutableStateOf(AsyncAction.Uninitialized)
|
mutableStateOf(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
val renderBlockedUsersDetail = featureFlagService
|
val renderBlockedUsersDetail by remember {
|
||||||
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
||||||
.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
|
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
|
||||||
val ignoredMatrixUser by produceState(
|
val ignoredMatrixUser by produceState(
|
||||||
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
|
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
|
||||||
key1 = renderBlockedUsersDetail.value,
|
key1 = renderBlockedUsersDetail,
|
||||||
key2 = ignoredUserIds
|
key2 = ignoredUserIds
|
||||||
) {
|
) {
|
||||||
value = ignoredUserIds.map {
|
value = ignoredUserIds.map {
|
||||||
if (renderBlockedUsersDetail.value) {
|
if (renderBlockedUsersDetail) {
|
||||||
matrixClient.getProfile(it).getOrNull()
|
matrixClient.getProfile(it).getOrNull()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,10 @@ class DeveloperSettingsPresenter @Inject constructor(
|
||||||
val clearCacheAction = remember {
|
val clearCacheAction = remember {
|
||||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
val customElementCallBaseUrl by appPreferencesStore
|
val customElementCallBaseUrl by remember {
|
||||||
.getCustomElementCallBaseUrlFlow()
|
appPreferencesStore
|
||||||
.collectAsState(initial = null)
|
.getCustomElementCallBaseUrlFlow()
|
||||||
|
}.collectAsState(initial = null)
|
||||||
|
|
||||||
val tracingLogLevelFlow = remember {
|
val tracingLogLevelFlow = remember {
|
||||||
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
|
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
|
||||||
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val appNotificationsEnabled = userPushStore
|
val appNotificationsEnabled by remember {
|
||||||
.getNotificationEnabledForDevice()
|
userPushStore.getNotificationEnabledForDevice()
|
||||||
.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
|
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
|
||||||
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
|
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
|
||||||
|
|
@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
||||||
matrixSettings = matrixSettings.value,
|
matrixSettings = matrixSettings.value,
|
||||||
appSettings = NotificationSettingsState.AppSettings(
|
appSettings = NotificationSettingsState.AppSettings(
|
||||||
systemNotificationsEnabled = systemNotificationsEnabled.value,
|
systemNotificationsEnabled = systemNotificationsEnabled.value,
|
||||||
appNotificationsEnabled = appNotificationsEnabled.value
|
appNotificationsEnabled = appNotificationsEnabled,
|
||||||
),
|
),
|
||||||
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
||||||
currentPushDistributor = currentDistributor,
|
currentPushDistributor = currentDistributor,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import android.content.Context
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.ftue.api.state.FtueService
|
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.features.preferences.impl.DefaultCacheService
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
|
@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||||
private val okHttpClient: Provider<OkHttpClient>,
|
private val okHttpClient: Provider<OkHttpClient>,
|
||||||
private val ftueService: FtueService,
|
private val ftueService: FtueService,
|
||||||
private val pushService: PushService,
|
private val pushService: PushService,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : ClearCacheUseCase {
|
) : ClearCacheUseCase {
|
||||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||||
// Clear Matrix cache
|
// Clear Matrix cache
|
||||||
|
|
@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
||||||
context.cacheDir.deleteRecursively()
|
context.cacheDir.deleteRecursively()
|
||||||
// Clear some settings
|
// Clear some settings
|
||||||
ftueService.reset()
|
ftueService.reset()
|
||||||
|
seenInvitesStore.clear()
|
||||||
// Ensure any error will be displayed again
|
// Ensure any error will be displayed again
|
||||||
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
|
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
|
||||||
// Ensure the app is restarted
|
// Ensure the app is restarted
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@
|
||||||
<string name="screen_advanced_settings_element_call_base_url">"Vlastná Element Call základná URL adresa"</string>
|
<string name="screen_advanced_settings_element_call_base_url">"Vlastná Element Call základná URL adresa"</string>
|
||||||
<string name="screen_advanced_settings_element_call_base_url_description">"Nastaviť vlastnú základnú URL adresu pre Element Call."</string>
|
<string name="screen_advanced_settings_element_call_base_url_description">"Nastaviť vlastnú základnú URL adresu pre Element Call."</string>
|
||||||
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."</string>
|
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."</string>
|
||||||
|
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti"</string>
|
||||||
|
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Skryť ukážky médií na časovej osi"</string>
|
||||||
<string name="screen_advanced_settings_media_compression_description">"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"</string>
|
<string name="screen_advanced_settings_media_compression_description">"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"</string>
|
||||||
<string name="screen_advanced_settings_media_compression_title">"Optimalizovať kvalitu médií"</string>
|
<string name="screen_advanced_settings_media_compression_title">"Optimalizovať kvalitu médií"</string>
|
||||||
|
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderovanie a bezpečnosť"</string>
|
||||||
<string name="screen_advanced_settings_push_provider_android">"Poskytovateľ oznámení Push"</string>
|
<string name="screen_advanced_settings_push_provider_android">"Poskytovateľ oznámení Push"</string>
|
||||||
<string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string>
|
<string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string>
|
||||||
<string name="screen_advanced_settings_send_read_receipts">"Potvrdenia o prečítaní"</string>
|
<string name="screen_advanced_settings_send_read_receipts">"Potvrdenia o prečítaní"</string>
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.ftue.test.FakeFtueService
|
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.features.preferences.impl.DefaultCacheService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.push.test.FakePushService
|
import io.element.android.libraries.push.test.FakePushService
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
|
||||||
val pushService = FakePushService(
|
val pushService = FakePushService(
|
||||||
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
|
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
|
||||||
)
|
)
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
|
||||||
val sut = DefaultClearCacheUseCase(
|
val sut = DefaultClearCacheUseCase(
|
||||||
context = InstrumentationRegistry.getInstrumentation().context,
|
context = InstrumentationRegistry.getInstrumentation().context,
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
|
@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
|
||||||
okHttpClient = { OkHttpClient.Builder().build() },
|
okHttpClient = { OkHttpClient.Builder().build() },
|
||||||
ftueService = ftueService,
|
ftueService = ftueService,
|
||||||
pushService = pushService,
|
pushService = pushService,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
defaultCacheService.clearedCacheEventFlow.test {
|
defaultCacheService.clearedCacheEventFlow.test {
|
||||||
sut.invoke()
|
sut.invoke()
|
||||||
|
|
@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
|
||||||
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
|
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
|
||||||
.with(value(matrixClient.sessionId), value(false))
|
.with(value(matrixClient.sessionId), value(false))
|
||||||
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
|
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
|
||||||
screenshotHolder.getFileUri()
|
screenshotHolder.getFileUri()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val crashInfo: String by crashDataStore
|
val crashInfo: String by remember {
|
||||||
.crashInfo()
|
crashDataStore.crashInfo()
|
||||||
.collectAsState(initial = "")
|
}.collectAsState(initial = "")
|
||||||
|
|
||||||
val sendingProgress = remember {
|
val sendingProgress = remember {
|
||||||
mutableFloatStateOf(0f)
|
mutableFloatStateOf(0f)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.rageshake.impl.preferences
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
||||||
mutableStateOf(rageshake.isAvailable())
|
mutableStateOf(rageshake.isAvailable())
|
||||||
}
|
}
|
||||||
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
|
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
|
||||||
val isEnabled = rageshakeDataStore
|
val isEnabled by remember {
|
||||||
.isEnabled()
|
rageshakeDataStore.isEnabled()
|
||||||
.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
val sensitivity = rageshakeDataStore
|
val sensitivity by remember {
|
||||||
.sensitivity()
|
rageshakeDataStore.sensitivity()
|
||||||
.collectAsState(initial = 0f)
|
}.collectAsState(initial = 0f)
|
||||||
|
|
||||||
fun handleEvents(event: RageshakePreferencesEvents) {
|
fun handleEvents(event: RageshakePreferencesEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
||||||
|
|
||||||
return RageshakePreferencesState(
|
return RageshakePreferencesState(
|
||||||
isFeatureEnabled = isFeatureAvailable,
|
isFeatureEnabled = isFeatureAvailable,
|
||||||
isEnabled = isEnabled.value,
|
isEnabled = isEnabled,
|
||||||
isSupported = isSupported.value,
|
isSupported = isSupported.value,
|
||||||
sensitivity = sensitivity.value,
|
sensitivity = sensitivity,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
|
||||||
data object LeaveRoom : RoomDetailsEvent
|
data object LeaveRoom : RoomDetailsEvent
|
||||||
data object MuteNotification : RoomDetailsEvent
|
data object MuteNotification : RoomDetailsEvent
|
||||||
data object UnmuteNotification : RoomDetailsEvent
|
data object UnmuteNotification : RoomDetailsEvent
|
||||||
|
data class CopyToClipboard(val text: String) : RoomDetailsEvent
|
||||||
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
|
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.roomcall.api.RoomCallState
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.isDmAsState
|
||||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
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.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||||
|
private val clipboardHelper: ClipboardHelper,
|
||||||
) : Presenter<RoomDetailsState> {
|
) : Presenter<RoomDetailsState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomDetailsState {
|
override fun present(): RoomDetailsState {
|
||||||
|
|
@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
|
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<Int?>(null) {
|
val knockRequestsCount by produceState<Int?>(null) {
|
||||||
room.knockRequestsFlow.collect { value = it.size }
|
room.knockRequestsFlow.collect { value = it.size }
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
|
|
||||||
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
||||||
|
|
||||||
|
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||||
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
||||||
fun handleEvents(event: RoomDetailsEvent) {
|
fun handleEvents(event: RoomDetailsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
RoomDetailsEvent.LeaveRoom ->
|
RoomDetailsEvent.LeaveRoom ->
|
||||||
|
|
@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
|
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,
|
canShowPinnedMessages = canShowPinnedMessages,
|
||||||
canShowMediaGallery = canShowMediaGallery,
|
canShowMediaGallery = canShowMediaGallery,
|
||||||
pinnedMessagesCount = pinnedMessagesCount,
|
pinnedMessagesCount = pinnedMessagesCount,
|
||||||
|
snackbarMessage = snackbarMessage,
|
||||||
canShowKnockRequests = canShowKnockRequests,
|
canShowKnockRequests = canShowKnockRequests,
|
||||||
knockRequestsCount = knockRequestsCount,
|
knockRequestsCount = knockRequestsCount,
|
||||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.roomcall.api.RoomCallState
|
import io.element.android.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.features.userprofile.api.UserProfileState
|
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.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
@ -42,6 +43,7 @@ data class RoomDetailsState(
|
||||||
val canShowPinnedMessages: Boolean,
|
val canShowPinnedMessages: Boolean,
|
||||||
val canShowMediaGallery: Boolean,
|
val canShowMediaGallery: Boolean,
|
||||||
val pinnedMessagesCount: Int?,
|
val pinnedMessagesCount: Int?,
|
||||||
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val canShowKnockRequests: Boolean,
|
val canShowKnockRequests: Boolean,
|
||||||
val knockRequestsCount: Int?,
|
val knockRequestsCount: Int?,
|
||||||
val canShowSecurityAndPrivacy: Boolean,
|
val canShowSecurityAndPrivacy: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -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.api.UserProfileVerificationState
|
||||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -111,6 +112,7 @@ fun aRoomDetailsState(
|
||||||
canShowPinnedMessages: Boolean = true,
|
canShowPinnedMessages: Boolean = true,
|
||||||
canShowMediaGallery: Boolean = true,
|
canShowMediaGallery: Boolean = true,
|
||||||
pinnedMessagesCount: Int? = null,
|
pinnedMessagesCount: Int? = null,
|
||||||
|
snackbarMessage: SnackbarMessage? = null,
|
||||||
canShowKnockRequests: Boolean = false,
|
canShowKnockRequests: Boolean = false,
|
||||||
knockRequestsCount: Int? = null,
|
knockRequestsCount: Int? = null,
|
||||||
canShowSecurityAndPrivacy: Boolean = true,
|
canShowSecurityAndPrivacy: Boolean = true,
|
||||||
|
|
@ -139,11 +141,12 @@ fun aRoomDetailsState(
|
||||||
canShowPinnedMessages = canShowPinnedMessages,
|
canShowPinnedMessages = canShowPinnedMessages,
|
||||||
canShowMediaGallery = canShowMediaGallery,
|
canShowMediaGallery = canShowMediaGallery,
|
||||||
pinnedMessagesCount = pinnedMessagesCount,
|
pinnedMessagesCount = pinnedMessagesCount,
|
||||||
|
snackbarMessage = snackbarMessage,
|
||||||
canShowKnockRequests = canShowKnockRequests,
|
canShowKnockRequests = canShowKnockRequests,
|
||||||
knockRequestsCount = knockRequestsCount,
|
knockRequestsCount = knockRequestsCount,
|
||||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||||
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
||||||
eventSink = eventSink
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aRoomNotificationSettings(
|
fun aRoomNotificationSettings(
|
||||||
|
|
|
||||||
|
|
@ -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.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
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.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
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.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -106,6 +109,7 @@ fun RoomDetailsView(
|
||||||
onProfileClick: (UserId) -> Unit,
|
onProfileClick: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -115,6 +119,7 @@ fun RoomDetailsView(
|
||||||
onActionClick = onActionClick
|
onActionClick = onActionClick
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -135,6 +140,9 @@ fun RoomDetailsView(
|
||||||
openAvatarPreview = { avatarUrl ->
|
openAvatarPreview = { avatarUrl ->
|
||||||
openAvatarPreview(state.roomName, avatarUrl)
|
openAvatarPreview(state.roomName, avatarUrl)
|
||||||
},
|
},
|
||||||
|
onSubtitleClick = { subtitle ->
|
||||||
|
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RoomDetailsType.Dm -> {
|
is RoomDetailsType.Dm -> {
|
||||||
|
|
@ -145,6 +153,9 @@ fun RoomDetailsView(
|
||||||
openAvatarPreview = { name, avatarUrl ->
|
openAvatarPreview = { name, avatarUrl ->
|
||||||
openAvatarPreview(name, avatarUrl)
|
openAvatarPreview(name, avatarUrl)
|
||||||
},
|
},
|
||||||
|
onSubtitleClick = { subtitle ->
|
||||||
|
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -368,6 +379,7 @@ private fun RoomHeaderSection(
|
||||||
roomAlias: RoomAlias?,
|
roomAlias: RoomAlias?,
|
||||||
heroes: ImmutableList<MatrixUser>,
|
heroes: ImmutableList<MatrixUser>,
|
||||||
openAvatarPreview: (url: String) -> Unit,
|
openAvatarPreview: (url: String) -> Unit,
|
||||||
|
onSubtitleClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -384,7 +396,11 @@ private fun RoomHeaderSection(
|
||||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||||
.testTag(TestTags.roomDetailAvatar)
|
.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,
|
otherMember: RoomMember,
|
||||||
roomName: String,
|
roomName: String,
|
||||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||||
|
onSubtitleClick: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -411,6 +428,7 @@ private fun DmHeaderSection(
|
||||||
TitleAndSubtitle(
|
TitleAndSubtitle(
|
||||||
title = roomName,
|
title = roomName,
|
||||||
subtitle = otherMember.userId.value,
|
subtitle = otherMember.userId.value,
|
||||||
|
onSubtitleClick = onSubtitleClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +437,7 @@ private fun DmHeaderSection(
|
||||||
private fun TitleAndSubtitle(
|
private fun TitleAndSubtitle(
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
|
onSubtitleClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
|
||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
|
@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
|
||||||
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
|
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
|
||||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
|
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
|
||||||
trailingContent =
|
trailingContent =
|
||||||
if (pinnedMessagesCount == null) {
|
if (pinnedMessagesCount == null) {
|
||||||
ListItemContent.Custom {
|
ListItemContent.Custom {
|
||||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
|
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
|
||||||
onPinnedMessagesClick()
|
onPinnedMessagesClick()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
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.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.EncryptionService
|
||||||
|
|
@ -25,6 +26,7 @@ object RoomMemberModule {
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||||
encryptionService: EncryptionService,
|
encryptionService: EncryptionService,
|
||||||
|
clipboardHelper: ClipboardHelper,
|
||||||
): RoomMemberDetailsPresenter.Factory {
|
): RoomMemberDetailsPresenter.Factory {
|
||||||
return object : RoomMemberDetailsPresenter.Factory {
|
return object : RoomMemberDetailsPresenter.Factory {
|
||||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||||
|
|
@ -33,6 +35,7 @@ object RoomMemberModule {
|
||||||
room = room,
|
room = room,
|
||||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
|
clipboardHelper = clipboardHelper,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.UserProfilePresenterFactory
|
||||||
import io.element.android.features.userprofile.api.UserProfileState
|
import io.element.android.features.userprofile.api.UserProfileState
|
||||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
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.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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
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.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
|
@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
@Assisted private val roomMemberId: UserId,
|
@Assisted private val roomMemberId: UserId,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
|
private val clipboardHelper: ClipboardHelper,
|
||||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||||
) : Presenter<UserProfileState> {
|
) : Presenter<UserProfileState> {
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
override fun present(): UserProfileState {
|
override fun present(): UserProfileState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||||
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Update room member info when opening this screen
|
// Update room member info when opening this screen
|
||||||
|
|
@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
|
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
|
||||||
encryptionService.withdrawVerification(roomMemberId)
|
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,
|
userName = roomUserName ?: userProfileState.userName,
|
||||||
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
|
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
|
||||||
verificationState = verificationState,
|
verificationState = verificationState,
|
||||||
eventSink = { event ->
|
snackbarMessage = snackbarMessage,
|
||||||
if (event is UserProfileEvents.WithdrawVerification) {
|
eventSink = ::eventSink
|
||||||
eventSink(UserProfileEvents.WithdrawVerification)
|
|
||||||
} else {
|
|
||||||
userProfileState.eventSink(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@
|
||||||
<item quantity="one">"%1$d osaleja"</item>
|
<item quantity="one">"%1$d osaleja"</item>
|
||||||
<item quantity="other">"%1$d osalejat"</item>
|
<item quantity="other">"%1$d osalejat"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_action">"Eemalda"</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_description">"Uue kutse saamisel on tal võimalik selle jututoaga uuesti liituda."</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_title">"Kas sa oled kindel, et soovid selle osaleja eemaldada?"</string>
|
||||||
<string name="screen_room_member_list_manage_member_ban">"Eemalda ja sea suhtluskeeld"</string>
|
<string name="screen_room_member_list_manage_member_ban">"Eemalda ja sea suhtluskeeld"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove">"Eemalda kasutaja jututoast"</string>
|
<string name="screen_room_member_list_manage_member_remove">"Eemalda kasutaja jututoast"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eemalda ja sea suhtluskeeld"</string>
|
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eemalda ja sea suhtluskeeld"</string>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@
|
||||||
<item quantity="one">"%1$d személy"</item>
|
<item quantity="one">"%1$d személy"</item>
|
||||||
<item quantity="other">"%1$d személy"</item>
|
<item quantity="other">"%1$d személy"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_action">"Eltávolítás"</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_description">"Ehhez a szobához is csatlakozhat, ha meghívják."</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_title">"Biztos, hogy eltávolítja ezt a tagot?"</string>
|
||||||
<string name="screen_room_member_list_manage_member_ban">"Eltávolítás és a tag kitiltása"</string>
|
<string name="screen_room_member_list_manage_member_ban">"Eltávolítás és a tag kitiltása"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove">"Eltávolítás a szobából"</string>
|
<string name="screen_room_member_list_manage_member_remove">"Eltávolítás a szobából"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eltávolítás és a tag kitiltása"</string>
|
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eltávolítás és a tag kitiltása"</string>
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@
|
||||||
<item quantity="few">"%1$d osoby"</item>
|
<item quantity="few">"%1$d osoby"</item>
|
||||||
<item quantity="other">"%1$d osôb"</item>
|
<item quantity="other">"%1$d osôb"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_action">"Odstrániť"</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_description">"V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti."</string>
|
||||||
|
<string name="screen_room_member_list_kick_member_confirmation_title">"Ste si istý, že chcete odstrániť tohto člena?"</string>
|
||||||
<string name="screen_room_member_list_manage_member_ban">"Odstrániť a zakázať člena"</string>
|
<string name="screen_room_member_list_manage_member_ban">"Odstrániť a zakázať člena"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove">"Odstrániť z miestnosti"</string>
|
<string name="screen_room_member_list_manage_member_remove">"Odstrániť z miestnosti"</string>
|
||||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odstrániť a zakázať člena"</string>
|
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odstrániť a zakázať člena"</string>
|
||||||
|
|
|
||||||