Merge branch 'develop' into feature/fga/advanced_settings_moderation_and_safety

This commit is contained in:
ganfra 2025-04-10 18:32:04 +02:00
commit 497022291b
278 changed files with 2632 additions and 1649 deletions

View file

@ -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:

View file

@ -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
============================= =============================

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

@ -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),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -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>

View file

@ -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

View file

@ -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,

View file

@ -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,

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

@ -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) {

View file

@ -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()
} }
} }
} }

View file

@ -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())

View file

@ -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)
} }

View file

@ -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) {

View file

@ -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())

View file

@ -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()
} }

View file

@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded() moveToNextStepIfNeeded()
}) })
analyticsService.didAskUserConsent() analyticsService.didAskUserConsentFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() } .onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)

View file

@ -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 {

View file

@ -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()
}

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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)
} }

View file

@ -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,
) )
} }
} }

View 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)
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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,
) )
} }
} }

View file

@ -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,
) )
} }

View file

@ -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))
}
} }

View file

@ -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)
}
} }

View file

@ -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)
} }

View file

@ -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 }

View file

@ -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)
} }

View file

@ -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),
) )
} }
} }

View file

@ -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,
)
}
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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(

View file

@ -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) {

View file

@ -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"))

View file

@ -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
) )

View file

@ -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,
) )

View file

@ -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,
) )

View file

@ -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
} }
} }
} }

View file

@ -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()

View file

@ -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()

View file

@ -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 }

View file

@ -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 = {

View file

@ -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,
),
)
}
} }
} }
} }

View file

@ -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) {

View file

@ -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

View file

@ -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()

View file

@ -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> {}

View file

@ -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> ->

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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()) }

View file

@ -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,

View file

@ -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

View file

@ -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>

View file

@ -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()
} }
} }
} }

View file

@ -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)

View file

@ -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
) )
} }

View file

@ -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
} }

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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()

View file

@ -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,
) )
} }
} }

View file

@ -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)
}
}
) )
} }
} }

View file

@ -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>

View file

@ -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>

View file

@ -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>

Some files were not shown because too many files have changed in this diff Show more