Merge branch 'develop' into feature/fga/advanced_settings_moderation_and_safety
2
.github/workflows/pull_request.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Pull Request
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
||||
workflow_call:
|
||||
secrets:
|
||||
|
|
|
|||
68
CHANGES.md
|
|
@ -1,3 +1,71 @@
|
|||
Changes in Element X v25.04.0
|
||||
=============================
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at v25.04.0 -->
|
||||
|
||||
## What's Changed
|
||||
### ✨ Features
|
||||
* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514
|
||||
* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530
|
||||
### 🙌 Improvements
|
||||
* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476
|
||||
* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507
|
||||
* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532
|
||||
### 🐛 Bugfixes
|
||||
* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482
|
||||
* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491
|
||||
* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495
|
||||
* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499
|
||||
* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478
|
||||
* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515
|
||||
* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536
|
||||
* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544
|
||||
* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543
|
||||
### 🧱 Build
|
||||
* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471
|
||||
* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490
|
||||
* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492
|
||||
* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494
|
||||
* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501
|
||||
* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523
|
||||
### Dependency upgrades
|
||||
* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483
|
||||
* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487
|
||||
* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469
|
||||
* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484
|
||||
* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493
|
||||
* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488
|
||||
* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498
|
||||
* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508
|
||||
* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516
|
||||
* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509
|
||||
* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444
|
||||
* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522
|
||||
* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527
|
||||
* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537
|
||||
* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540
|
||||
* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547
|
||||
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548
|
||||
### Others
|
||||
* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497
|
||||
* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496
|
||||
* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512
|
||||
* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513
|
||||
* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521
|
||||
* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531
|
||||
* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533
|
||||
* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545
|
||||
* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534
|
||||
* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549
|
||||
* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432
|
||||
* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0
|
||||
|
||||
Changes in Element X v25.03.4
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<resources>
|
||||
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
|
||||
<!-- Must be equal to DarkColorTokens.colorThemeBg -->
|
||||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -10,25 +10,28 @@ package io.element.android.appicon.element
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun IconPreview() {
|
||||
Box {
|
||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
||||
Image(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
painter = painterResource(id = R.drawable.ic_launcher_background),
|
||||
contentDescription = null,
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,8 +39,15 @@ internal fun IconPreview() {
|
|||
@Composable
|
||||
internal fun RoundIconPreview() {
|
||||
Box(modifier = Modifier.clip(shape = CircleShape)) {
|
||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
||||
Image(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
painter = painterResource(id = R.drawable.ic_launcher_background),
|
||||
contentDescription = null,
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
|
|||
internal fun MonochromeIconPreview() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.background(Color(0xFF2F3133))
|
||||
.clip(shape = RoundedCornerShape(32.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
.background(Color(0xFF2F3133)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,2 +1,10 @@
|
|||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:src="@mipmap/ic_launcher_background" />
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#010302"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
|||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
|
|
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
|
|||
.launchIn(this)
|
||||
}
|
||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
val showSyncSpinner by remember {
|
||||
derivedStateOf {
|
||||
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
LoadingRoomNodeView(
|
||||
state = LoadingRoomState.Loading,
|
||||
hasNetworkConnection = isOnline,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
|
|||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
|||
|
||||
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
|
||||
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
LoadingRoomNodeView(
|
||||
state = loadingRoomState,
|
||||
hasNetworkConnection = isOnline,
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202504000.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Main changes in this version: bug fixes and improvements.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): AnalyticsPreferencesState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val isEnabled = analyticsService.getUserConsent()
|
||||
.collectAsState(initial = false)
|
||||
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
|
||||
|
||||
fun handleEvents(event: AnalyticsOptInEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.getUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||
assertThat(analyticsService.userConsentFlow.first()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
||||
assertThat(analyticsService.getUserConsent().first()).isFalse()
|
||||
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||
assertThat(analyticsService.userConsentFlow.first()).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
|
|||
}
|
||||
|
||||
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
||||
selectedUserListDataStore.selectedUsers(),
|
||||
selectedUserListDataStore.selectedUsers,
|
||||
createRoomConfigFlow,
|
||||
) { selectedUsers, config ->
|
||||
config.copy(invites = selectedUsers.toImmutableList())
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
|
|||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
||||
val homeserverName = remember { matrixClient.userIdServerName() }
|
||||
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
|
||||
val isKnockFeatureEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||
}.collectAsState(initial = false)
|
||||
val roomAddressValidity = remember {
|
||||
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
val localCoroutineScope = rememberCoroutineScope()
|
||||
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) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
||||
mutableStateOf(SearchBarResultState.Initial())
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@
|
|||
package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class UserListDataStore @Inject constructor() {
|
||||
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||
|
||||
fun selectUser(user: MatrixUser) {
|
||||
if (!selectedUsers.value.contains(user)) {
|
||||
selectedUsers.tryEmit(selectedUsers.value.plus(user))
|
||||
if (!_selectedUsers.value.contains(user)) {
|
||||
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeUserFromSelection(user: MatrixUser) {
|
||||
selectedUsers.tryEmit(selectedUsers.value.minus(user))
|
||||
_selectedUsers.tryEmit(_selectedUsers.value.minus(user))
|
||||
}
|
||||
|
||||
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers
|
||||
val selectedUsers = _selectedUsers.asStateFlow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
moveToNextStepIfNeeded()
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
|
|||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
|
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun needsAnalyticsOptIn(): Boolean {
|
||||
return analyticsService.didAskUserConsent().first().not()
|
||||
return analyticsService.didAskUserConsentFlow.first().not()
|
||||
}
|
||||
|
||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SeenInvitesStore {
|
||||
/**
|
||||
* Returns a flow of seen room IDs of invitation.
|
||||
*/
|
||||
fun seenRoomIds(): Flow<Set<RoomId>>
|
||||
|
||||
/**
|
||||
* Mark the invitation as seen.
|
||||
* Call this when the invitation details are shown to the user.
|
||||
* @param roomId the room ID of the invitation to mark as seen.
|
||||
*/
|
||||
suspend fun markAsSeen(roomId: RoomId)
|
||||
|
||||
/**
|
||||
* Mark the invitation as unseen.
|
||||
* Call this when the invitation has been accepted or declined.
|
||||
* @param roomId the room ID of the invitation to mark as unseen.
|
||||
*/
|
||||
suspend fun markAsUnSeen(roomId: RoomId)
|
||||
|
||||
/**
|
||||
* Delete the store.
|
||||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ setupAnvil()
|
|||
dependencies {
|
||||
api(projects.features.invite.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
@ -35,6 +36,7 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSeenInvitesStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
||||
sessionObserver: SessionObserver,
|
||||
) : SeenInvitesStore {
|
||||
private val sessionId: SessionId = currentSessionIdHolder.current
|
||||
|
||||
init {
|
||||
sessionObserver.addListener(object : SessionListener {
|
||||
override suspend fun onSessionCreated(userId: String) = Unit
|
||||
override suspend fun onSessionDeleted(userId: String) {
|
||||
if (sessionId.value == userId) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
|
||||
context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
|
||||
}
|
||||
|
||||
private val store = PreferenceDataStoreFactory.create(
|
||||
scope = sessionCoroutineScope,
|
||||
migrations = emptyList(),
|
||||
) {
|
||||
dataStoreFile
|
||||
}
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> =
|
||||
store.data.map { prefs ->
|
||||
prefs[seenInvitesKey]
|
||||
.orEmpty()
|
||||
.map { RoomId(it) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override suspend fun markAsSeen(roomId: RoomId) {
|
||||
store.edit { prefs ->
|
||||
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAsUnSeen(roomId: RoomId) {
|
||||
store.edit { prefs ->
|
||||
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
dataStoreFile.safeDelete()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||
|
|
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
private val client: MatrixClient,
|
||||
private val joinRoom: JoinRoom,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : Presenter<AcceptDeclineInviteState> {
|
||||
@Composable
|
||||
override fun present(): AcceptDeclineInviteState {
|
||||
|
|
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
)
|
||||
.onSuccess {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||
seenInvitesStore.markAsUnSeen(roomId)
|
||||
}
|
||||
.map { roomId }
|
||||
}
|
||||
|
|
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
|||
client.ignoreUser(inviteData.senderId).getOrThrow()
|
||||
}
|
||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
||||
seenInvitesStore.markAsUnSeen(inviteData.roomId)
|
||||
inviteData.roomId
|
||||
}.runCatchingUpdatingState(declinedAction)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
|
|
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
|
|||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - declining invite cancel flow`() = runTest {
|
||||
val presenter = createAcceptDeclineInvitePresenter()
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||
}
|
||||
)
|
||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
assert(declineInviteFailure).isCalledOnce()
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
||||
}
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
},
|
||||
ignoreUserResult = ignoreUserSuccess
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||
}
|
||||
)
|
||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
}
|
||||
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
||||
}
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||
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 {
|
||||
val inviteData = anInviteData()
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
value(emptyList<String>()),
|
||||
value(JoinedRoom.Trigger.Invite)
|
||||
)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
|
|||
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||
val presenter = createAcceptDeclineInvitePresenter(
|
||||
joinRoomLambda = joinRoomSuccess,
|
||||
notificationCleaner = fakeNotificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
presenter.test {
|
||||
val inviteData = anInviteData()
|
||||
|
|
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
|
|||
clearMembershipNotificationForRoomLambda.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
}
|
||||
|
||||
private fun anInviteData(
|
||||
|
|
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
|
|||
Result.success(Unit)
|
||||
},
|
||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||
): AcceptDeclineInvitePresenter {
|
||||
return AcceptDeclineInvitePresenter(
|
||||
client = client,
|
||||
joinRoom = FakeJoinRoom(joinRoomLambda),
|
||||
notificationCleaner = notificationCleaner,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
features/invite/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (c) 2025 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.invite.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
api(projects.features.invite.api)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.test
|
||||
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class InMemorySeenInvitesStore(
|
||||
initialRoomIds: Set<RoomId> = emptySet(),
|
||||
) : SeenInvitesStore {
|
||||
private val roomIds = MutableStateFlow(initialRoomIds)
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> = roomIds
|
||||
|
||||
override suspend fun markAsSeen(roomId: RoomId) {
|
||||
roomIds.value += roomId
|
||||
}
|
||||
|
||||
override suspend fun markAsUnSeen(roomId: RoomId) {
|
||||
roomIds.value -= roomId
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
roomIds.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ dependencies {
|
|||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
|
|
@ -69,6 +71,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : Presenter<JoinRoomState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
|
|
@ -84,7 +87,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
override fun present(): JoinRoomState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var retryCount by remember { mutableIntStateOf(0) }
|
||||
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
|
||||
val roomInfo by remember {
|
||||
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
|
||||
}.collectAsState(initial = Optional.empty())
|
||||
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val knockAction: 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()
|
||||
|
||||
LaunchedEffect(contentState) {
|
||||
contentState.markRoomInviteAsSeen()
|
||||
}
|
||||
|
||||
fun handleEvents(event: JoinRoomEvents) {
|
||||
when (event) {
|
||||
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
||||
|
|
@ -238,6 +247,12 @@ class JoinRoomPresenter @AssistedInject constructor(
|
|||
forgetRoom.invoke(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ContentState.markRoomInviteAsSeen() {
|
||||
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
|
||||
seenInvitesStore.markAsSeen(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
|
|||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
|
|
@ -37,6 +38,7 @@ object JoinRoomModule {
|
|||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
buildMeta: BuildMeta,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
seenInvitesStore: SeenInvitesStore,
|
||||
): JoinRoomPresenter.Factory {
|
||||
return object : JoinRoomPresenter.Factory {
|
||||
override fun create(
|
||||
|
|
@ -60,6 +62,7 @@ object JoinRoomModule {
|
|||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
buildMeta = buildMeta,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
|
||||
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
||||
|
|
@ -54,6 +56,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -113,14 +116,19 @@ class JoinRoomPresenterTest {
|
|||
flowOf(Optional.of(roomSummary))
|
||||
}
|
||||
}
|
||||
val seenInvitesStore = InMemorySeenInvitesStore()
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = matrixClient
|
||||
matrixClient = matrixClient,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
||||
}
|
||||
// Check that the roomId is stored in the seen invites store
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -762,7 +770,8 @@ class JoinRoomPresenterTest {
|
|||
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||
): JoinRoomPresenter {
|
||||
return JoinRoomPresenter(
|
||||
roomId = roomId,
|
||||
|
|
@ -778,6 +787,7 @@ class JoinRoomPresenterTest {
|
|||
buildMeta = buildMeta,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.net.toUri
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
|
|
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
|
|||
) : LocationActions {
|
||||
override fun share(location: Location, label: String?) {
|
||||
runCatching {
|
||||
val uri = Uri.parse(buildUrl(location, label))
|
||||
val uri = buildUrl(location, label).toUri()
|
||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
|
@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||
@VisibleForTesting
|
||||
internal fun buildUrl(
|
||||
location: Location,
|
||||
label: String?,
|
||||
urlEncoder: (String) -> String = Uri::encode
|
||||
): String {
|
||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
|
||||
return if (label == null) {
|
||||
base
|
||||
} else {
|
||||
"%s (%s)".format(base, urlEncoder(label))
|
||||
}
|
||||
// This is needed so the coordinates are formatted with a dot as decimal separator
|
||||
val locale = Locale.ENGLISH
|
||||
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
|||
import io.element.android.features.location.api.Location
|
||||
import org.junit.Test
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
|
||||
internal class AndroidLocationActionsTest {
|
||||
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
||||
|
|
@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
|
|||
)
|
||||
|
||||
val actual = buildUrl(location, null, ::urlEncoder)
|
||||
val expected = "geo:0,0?q=1.234568,123.456789"
|
||||
val expected = "geo:0,0?q=1.234568,123.456789 ()"
|
||||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
|
|||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
|
||||
val location = Location(
|
||||
lat = 1.000001,
|
||||
lon = 2.000001,
|
||||
accuracy = 0f
|
||||
)
|
||||
// Set a locale with comma as decimal separator
|
||||
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
|
||||
|
||||
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
|
||||
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
|
||||
|
||||
assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.features.lockscreen.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
|
|
@ -30,6 +34,8 @@ dependencies {
|
|||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.cryptography.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
|
|
@ -42,6 +48,9 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.cryptography.test)
|
||||
|
|
@ -50,4 +59,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
|
|||
|
||||
@Composable
|
||||
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
|
||||
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||
val isBiometricAllowed by remember {
|
||||
lockScreenStore.isBiometricUnlockAllowed()
|
||||
}.collectAsState(initial = false)
|
||||
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
val isAvailable by remember(lifecycleState) {
|
||||
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
|
|||
value = !lockScreenConfig.isPinMandatory && hasPinCode
|
||||
}
|
||||
}
|
||||
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||
val isBiometricEnabled by remember {
|
||||
lockScreenStore.isBiometricUnlockAllowed()
|
||||
}.collectAsState(initial = false)
|
||||
var showRemovePinConfirmation by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.time.digit
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -60,7 +68,22 @@ fun PinKeypad(
|
|||
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
|
||||
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
|
||||
Column(
|
||||
modifier = modifier,
|
||||
modifier = modifier.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp) {
|
||||
val digitChar = event.digit
|
||||
if (digitChar != null) {
|
||||
onClick(PinKeypadModel.Number(digitChar))
|
||||
true
|
||||
} else if (event.key == Key.Backspace) {
|
||||
onClick(PinKeypadModel.Back)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
) {
|
||||
|
|
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
||||
contentDescription = null,
|
||||
contentDescription = stringResource(CommonStrings.a11y_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock.keypad
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isRoot
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.requestFocus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PinKeypadTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on a number emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNode(hasText("1")).performClick()
|
||||
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the delete previous character button emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
|
||||
eventsRecorder.assertSingle(PinKeypadModel.Back)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun `typing using the hardware keyboard emits the expected events`() {
|
||||
val eventsRecorder = EventsRecorder<PinKeypadModel>()
|
||||
rule.setPinKeyPad(onClick = eventsRecorder)
|
||||
rule.onNodeWithText("1").requestFocus()
|
||||
rule.onAllNodes(isRoot())[0].performKeyInput {
|
||||
val keys = listOf(
|
||||
Key.A,
|
||||
Key.NumPad1,
|
||||
Key.NumPad2,
|
||||
Key.NumPad3,
|
||||
Key.NumPad4,
|
||||
Key.NumPad5,
|
||||
Key.NumPad6,
|
||||
Key.NumPad7,
|
||||
Key.NumPad8,
|
||||
Key.NumPad9,
|
||||
Key.NumPad0,
|
||||
Key(KeyEvent.KEYCODE_1),
|
||||
Key(KeyEvent.KEYCODE_2),
|
||||
Key(KeyEvent.KEYCODE_3),
|
||||
Key(KeyEvent.KEYCODE_4),
|
||||
Key(KeyEvent.KEYCODE_5),
|
||||
Key(KeyEvent.KEYCODE_6),
|
||||
Key(KeyEvent.KEYCODE_7),
|
||||
Key(KeyEvent.KEYCODE_8),
|
||||
Key(KeyEvent.KEYCODE_9),
|
||||
Key(KeyEvent.KEYCODE_0),
|
||||
Key.Backspace,
|
||||
)
|
||||
for (key in keys) {
|
||||
pressKey(key)
|
||||
}
|
||||
}
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
// Note that the first key is not a number, but a letter so it's ignored as input
|
||||
// Then we have the numpad keys
|
||||
PinKeypadModel.Number('1'),
|
||||
PinKeypadModel.Number('2'),
|
||||
PinKeypadModel.Number('3'),
|
||||
PinKeypadModel.Number('4'),
|
||||
PinKeypadModel.Number('5'),
|
||||
PinKeypadModel.Number('6'),
|
||||
PinKeypadModel.Number('7'),
|
||||
PinKeypadModel.Number('8'),
|
||||
PinKeypadModel.Number('9'),
|
||||
PinKeypadModel.Number('0'),
|
||||
// And the normal keys from the number row in the keyboard
|
||||
PinKeypadModel.Number('1'),
|
||||
PinKeypadModel.Number('2'),
|
||||
PinKeypadModel.Number('3'),
|
||||
PinKeypadModel.Number('4'),
|
||||
PinKeypadModel.Number('5'),
|
||||
PinKeypadModel.Number('6'),
|
||||
PinKeypadModel.Number('7'),
|
||||
PinKeypadModel.Number('8'),
|
||||
PinKeypadModel.Number('9'),
|
||||
PinKeypadModel.Number('0'),
|
||||
PinKeypadModel.Back,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
|
||||
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
PinKeypad(
|
||||
onClick = onClick,
|
||||
maxWidth = 1000.dp,
|
||||
maxHeight = 1000.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
|
|||
defaultAccountProvider
|
||||
)
|
||||
|
||||
fun flow(): StateFlow<AccountProvider> {
|
||||
return accountProvider.asStateFlow()
|
||||
}
|
||||
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
|
||||
|
||||
fun reset() {
|
||||
accountProvider.tryEmit(defaultAccountProvider)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): ConfirmAccountProviderState {
|
||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
|
|||
val parser = Json { ignoreUnknownKeys = true }
|
||||
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
|
||||
val userId = response.userId ?: error("No user ID in response")
|
||||
val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
|
||||
val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
|
||||
val accessToken = response.accessToken ?: error("No access token in response")
|
||||
val deviceId = response.deviceId ?: error("No device ID in response")
|
||||
return ExternalSession(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
|
|||
val formState = rememberSaveable {
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
}
|
||||
val accountProvider by accountProviderDataSource.flow().collectAsState()
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
|
||||
fun handleEvents(event: LoginPasswordEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
|
|||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
AccountProvider(
|
||||
|
|
@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
|
|||
val sut = AccountProviderDataSource(FakeEnterpriseService(
|
||||
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
|
||||
))
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(
|
||||
AccountProvider(
|
||||
|
|
@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
|
|||
@Test
|
||||
fun `present - user change and reset`() = runTest {
|
||||
val sut = AccountProviderDataSource(FakeEnterpriseService())
|
||||
sut.flow().test {
|
||||
sut.flow.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
|
||||
sut.userSelection(AccountProvider(url = "https://example.com"))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
|
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
|
|||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
|
|||
}
|
||||
.collectAsState(initial = BackupUploadState.Unknown)
|
||||
|
||||
var waitingForALongTime by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(backupUploadState) {
|
||||
if (backupUploadState is BackupUploadState.Waiting) {
|
||||
delay(2_000)
|
||||
waitingForALongTime = true
|
||||
} else {
|
||||
waitingForALongTime = false
|
||||
}
|
||||
}
|
||||
|
||||
val isLastDevice by encryptionService.isLastDevice.collectAsState()
|
||||
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
|
|
@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
|
|||
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
|
||||
recoveryState = recoveryState,
|
||||
backupUploadState = backupUploadState,
|
||||
waitingForALongTime = waitingForALongTime,
|
||||
logoutAction = logoutAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ data class LogoutState(
|
|||
val doesBackupExistOnServer: Boolean,
|
||||
val recoveryState: RecoveryState,
|
||||
val backupUploadState: BackupUploadState,
|
||||
val waitingForALongTime: Boolean,
|
||||
val logoutAction: AsyncAction<Unit>,
|
||||
val eventSink: (LogoutEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
|
|||
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
|
||||
// Last session no backup
|
||||
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
|
||||
aLogoutState(
|
||||
isLastDevice = false,
|
||||
backupUploadState = BackupUploadState.Waiting,
|
||||
),
|
||||
aLogoutState(
|
||||
isLastDevice = false,
|
||||
backupUploadState = BackupUploadState.Waiting,
|
||||
waitingForALongTime = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +47,7 @@ fun aLogoutState(
|
|||
doesBackupExistOnServer: Boolean = true,
|
||||
recoveryState: RecoveryState = RecoveryState.ENABLED,
|
||||
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
|
||||
waitingForALongTime: Boolean = false,
|
||||
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (LogoutEvents) -> Unit = {},
|
||||
) = LogoutState(
|
||||
|
|
@ -46,6 +56,7 @@ fun aLogoutState(
|
|||
doesBackupExistOnServer = doesBackupExistOnServer,
|
||||
recoveryState = recoveryState,
|
||||
backupUploadState = backupUploadState,
|
||||
waitingForALongTime = waitingForALongTime,
|
||||
logoutAction = logoutAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
|
|||
@Composable
|
||||
private fun Content(
|
||||
state: LogoutState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.backupUploadState is BackupUploadState.Uploading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
when (state.backupUploadState) {
|
||||
is BackupUploadState.Uploading -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
BackupUploadState.Waiting -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
if (state.waitingForALongTime) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
text = stringResource(CommonStrings.common_please_check_internet_connection),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class LogoutPresenterTest {
|
|||
assertThat(initialState.doesBackupExistOnServer).isTrue()
|
||||
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +67,34 @@ class LogoutPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - waiting a long time`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
encryptionService.givenWaitForBackupUploadSteadyStateFlow(
|
||||
flow {
|
||||
emit(BackupUploadState.Waiting)
|
||||
delay(3_000)
|
||||
}
|
||||
)
|
||||
val presenter = createLogoutPresenter(
|
||||
encryptionService = encryptionService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
val waitingState = awaitItem()
|
||||
assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
|
||||
assertThat(initialState.waitingForALongTime).isFalse()
|
||||
skipItems(1)
|
||||
val waitingALongTimeState = awaitItem()
|
||||
assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
|
||||
assertThat(waitingALongTimeState.waitingForALongTime).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - backing up`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
|
|
@ -183,7 +182,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
|
||||
}
|
||||
}
|
||||
val isOnline by syncService.isOnline().collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
|||
mutableStateOf(ActionListState.Target.None)
|
||||
}
|
||||
|
||||
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
|
||||
val pinnedEventIds by remember {
|
||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.allFiles
|
||||
|
|
@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
|
||||
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
|
||||
val allowCaption by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation)
|
||||
}.collectAsState(initial = false)
|
||||
val showCaptionCompatibilityWarning by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning)
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
var useSendQueue by remember { mutableStateOf(false) }
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
|
@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
dismissAfterSend = !useSendQueue,
|
||||
replyParameters = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
dismissAfterSend: Boolean,
|
||||
replyParameters: ReplyParameters?,
|
||||
) = runCatching {
|
||||
val context = coroutineContext
|
||||
val progressCallback = object : ProgressCallback {
|
||||
|
|
@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
mediaUploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
progressCallback = progressCallback
|
||||
progressCallback = progressCallback,
|
||||
replyParameters = replyParameters,
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
|
|
@ -177,7 +178,9 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
}
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
val sendTypingNotifications by remember {
|
||||
sessionPreferencesStore.isSendTypingNotificationsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
if (cameraPermissionState.permissionGranted) {
|
||||
|
|
@ -397,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
.stateIn(this, SharingStarted.Lazily, emptyList())
|
||||
|
||||
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
}
|
||||
val result = suggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
roomAliasSuggestions = roomAliasSuggestions,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
suggestions.clear()
|
||||
suggestions.addAll(result)
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -450,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
|
|||
}
|
||||
is MessageComposerMode.Reply -> {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
|
||||
with(capturedMode) {
|
||||
replyMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
intentionalMentions = message.intentionalMentions,
|
||||
replyParameters = ReplyParameters(
|
||||
inReplyToEventId = eventId,
|
||||
enforceThreadReply = inThread,
|
||||
// This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
|
||||
replyWithinThread = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
|
||||
|
||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
|
||||
val isLive by timelineController.isLive().collectAsState(initial = true)
|
||||
val isSendPublicReadReceiptsEnabled by remember {
|
||||
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val renderReadReceipts by remember {
|
||||
sessionPreferencesStore.isRenderReadReceiptsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val isLive by remember {
|
||||
timelineController.isLive()
|
||||
}.collectAsState(initial = true)
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannedString
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
|
|
@ -71,7 +71,7 @@ fun TimelineItemTextView(
|
|||
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
|
||||
val mentionSpanUpdater = LocalMentionSpanUpdater.current
|
||||
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
|
||||
return SpannableString(bodyWithResolvedMentions)
|
||||
return SpannedString.valueOf(bodyWithResolvedMentions)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
|
|||
) : Presenter<TypingNotificationState> {
|
||||
@Composable
|
||||
override fun present(): TypingNotificationState {
|
||||
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
|
||||
val renderTypingNotifications by remember {
|
||||
sessionPreferencesStore.isRenderTypingNotificationsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
|
||||
if (renderTypingNotifications) {
|
||||
observeRoomTypingMembers()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
|
|||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
|
|
@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media after pre-processing success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - send media before pre-processing success scenario`() = runTest {
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send image with caption success scenario`() = runTest {
|
||||
val sendImageResult =
|
||||
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send video with caption success scenario`() = runTest {
|
||||
val sendVideoResult =
|
||||
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send audio with caption success scenario`() = runTest {
|
||||
val sendAudioResult =
|
||||
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
|
|
@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
|
|||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
|
|
@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send media failure scenario without media queue`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
|
|
@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
|
|||
@Test
|
||||
fun `present - send media failure scenario with media queue`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
val sendFileResult =
|
||||
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val onDoneListenerResult = lambdaRecorder<Unit> {}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
|
|
@ -611,7 +612,7 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
|
|
@ -1110,7 +1111,7 @@ class MessageComposerPresenterTest {
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - send messages with intentional mentions`() = runTest {
|
||||
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
|
|||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
|
|
@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
private val sendVoiceMessageResult =
|
||||
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
|
||||
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
private val matrixRoom = FakeMatrixRoom(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): MigrationState {
|
||||
val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null)
|
||||
val migrationStoreVersion by remember {
|
||||
migrationStore.applicationMigrationVersion()
|
||||
}.collectAsState(initial = null)
|
||||
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
|
||||
// Uncomment this block to run the migration everytime
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): PollHistoryState {
|
||||
val timeline = room.liveTimeline
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val paginationState by timeline.backwardPaginationStatus.collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
pollHistoryItemFactory.create(items)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ dependencies {
|
|||
implementation(projects.features.licenses.api)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.features.deactivation.api)
|
||||
implementation(projects.features.invite.api)
|
||||
implementation(projects.features.roomlist.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
|
|
@ -103,6 +104,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushstore.test)
|
||||
testImplementation(projects.features.ftue.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.features.rageshake.test)
|
||||
testImplementation(projects.features.rageshake.impl)
|
||||
testImplementation(projects.features.logout.test)
|
||||
|
|
|
|||
|
|
@ -30,19 +30,18 @@ class AdvancedSettingsPresenter @Inject constructor(
|
|||
@Composable
|
||||
override fun present(): AdvancedSettingsState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val isDeveloperModeEnabled by appPreferencesStore
|
||||
.isDeveloperModeEnabledFlow()
|
||||
.collectAsState(initial = false)
|
||||
val isSharePresenceEnabled by sessionPreferencesStore
|
||||
.isSharePresenceEnabled()
|
||||
.collectAsState(initial = true)
|
||||
val doesCompressMedia by sessionPreferencesStore
|
||||
.doesCompressMedia()
|
||||
.collectAsState(initial = true)
|
||||
val isDeveloperModeEnabled by remember {
|
||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||
}.collectAsState(initial = false)
|
||||
val isSharePresenceEnabled by remember {
|
||||
sessionPreferencesStore.isSharePresenceEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val doesCompressMedia by remember {
|
||||
sessionPreferencesStore.doesCompressMedia()
|
||||
}.collectAsState(initial = true)
|
||||
val theme by remember {
|
||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
}.collectAsState(initial = Theme.System)
|
||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val hideInviteAvatars by remember {
|
||||
|
|
|
|||
|
|
@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
|
|||
mutableStateOf(AsyncAction.Uninitialized)
|
||||
}
|
||||
|
||||
val renderBlockedUsersDetail = featureFlagService
|
||||
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
||||
.collectAsState(initial = false)
|
||||
val renderBlockedUsersDetail by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
|
||||
}.collectAsState(initial = false)
|
||||
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
|
||||
val ignoredMatrixUser by produceState(
|
||||
initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
|
||||
key1 = renderBlockedUsersDetail.value,
|
||||
key1 = renderBlockedUsersDetail,
|
||||
key2 = ignoredUserIds
|
||||
) {
|
||||
value = ignoredUserIds.map {
|
||||
if (renderBlockedUsersDetail.value) {
|
||||
if (renderBlockedUsersDetail) {
|
||||
matrixClient.getProfile(it).getOrNull()
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -71,9 +71,10 @@ class DeveloperSettingsPresenter @Inject constructor(
|
|||
val clearCacheAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val customElementCallBaseUrl by appPreferencesStore
|
||||
.getCustomElementCallBaseUrlFlow()
|
||||
.collectAsState(initial = null)
|
||||
val customElementCallBaseUrl by remember {
|
||||
appPreferencesStore
|
||||
.getCustomElementCallBaseUrlFlow()
|
||||
}.collectAsState(initial = null)
|
||||
|
||||
val tracingLogLevelFlow = remember {
|
||||
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val appNotificationsEnabled = userPushStore
|
||||
.getNotificationEnabledForDevice()
|
||||
.collectAsState(initial = false)
|
||||
val appNotificationsEnabled by remember {
|
||||
userPushStore.getNotificationEnabledForDevice()
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
|
||||
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
|
||||
|
|
@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
|
|||
matrixSettings = matrixSettings.value,
|
||||
appSettings = NotificationSettingsState.AppSettings(
|
||||
systemNotificationsEnabled = systemNotificationsEnabled.value,
|
||||
appNotificationsEnabled = appNotificationsEnabled.value
|
||||
appNotificationsEnabled = appNotificationsEnabled,
|
||||
),
|
||||
changeNotificationSettingAction = changeNotificationSettingAction.value,
|
||||
currentPushDistributor = currentDistributor,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import android.content.Context
|
|||
import coil3.SingletonImageLoader
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
|
|
@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
private val okHttpClient: Provider<OkHttpClient>,
|
||||
private val ftueService: FtueService,
|
||||
private val pushService: PushService,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
) : ClearCacheUseCase {
|
||||
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
|
||||
// Clear Matrix cache
|
||||
|
|
@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
|
|||
context.cacheDir.deleteRecursively()
|
||||
// Clear some settings
|
||||
ftueService.reset()
|
||||
seenInvitesStore.clear()
|
||||
// Ensure any error will be displayed again
|
||||
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
|
||||
// Ensure the app is restarted
|
||||
|
|
|
|||
|
|
@ -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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.ftue.test.FakeFtueService
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.push.test.FakePushService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
|
|
@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
|
|||
val pushService = FakePushService(
|
||||
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
|
||||
)
|
||||
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
|
||||
val sut = DefaultClearCacheUseCase(
|
||||
context = InstrumentationRegistry.getInstrumentation().context,
|
||||
matrixClient = matrixClient,
|
||||
|
|
@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
|
|||
okHttpClient = { OkHttpClient.Builder().build() },
|
||||
ftueService = ftueService,
|
||||
pushService = pushService,
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
)
|
||||
defaultCacheService.clearedCacheEventFlow.test {
|
||||
sut.invoke()
|
||||
|
|
@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
|
|||
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
|
||||
.with(value(matrixClient.sessionId), value(false))
|
||||
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
|
||||
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
|
|||
screenshotHolder.getFileUri()
|
||||
)
|
||||
}
|
||||
val crashInfo: String by crashDataStore
|
||||
.crashInfo()
|
||||
.collectAsState(initial = "")
|
||||
val crashInfo: String by remember {
|
||||
crashDataStore.crashInfo()
|
||||
}.collectAsState(initial = "")
|
||||
|
||||
val sendingProgress = remember {
|
||||
mutableFloatStateOf(0f)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.rageshake.impl.preferences
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
|||
mutableStateOf(rageshake.isAvailable())
|
||||
}
|
||||
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
|
||||
val isEnabled = rageshakeDataStore
|
||||
.isEnabled()
|
||||
.collectAsState(initial = false)
|
||||
val isEnabled by remember {
|
||||
rageshakeDataStore.isEnabled()
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val sensitivity = rageshakeDataStore
|
||||
.sensitivity()
|
||||
.collectAsState(initial = 0f)
|
||||
val sensitivity by remember {
|
||||
rageshakeDataStore.sensitivity()
|
||||
}.collectAsState(initial = 0f)
|
||||
|
||||
fun handleEvents(event: RageshakePreferencesEvents) {
|
||||
when (event) {
|
||||
|
|
@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
|
|||
|
||||
return RageshakePreferencesState(
|
||||
isFeatureEnabled = isFeatureAvailable,
|
||||
isEnabled = isEnabled.value,
|
||||
isEnabled = isEnabled,
|
||||
isSupported = isSupported.value,
|
||||
sensitivity = sensitivity.value,
|
||||
sensitivity = sensitivity,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
|
|||
data object LeaveRoom : RoomDetailsEvent
|
||||
data object MuteNotification : RoomDetailsEvent
|
||||
data object UnmuteNotification : RoomDetailsEvent
|
||||
data class CopyToClipboard(val text: String) : RoomDetailsEvent
|
||||
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
|
|||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
|||
import io.element.android.libraries.matrix.ui.room.isDmAsState
|
||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
) : Presenter<RoomDetailsState> {
|
||||
@Composable
|
||||
override fun present(): RoomDetailsState {
|
||||
|
|
@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
|
||||
val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false)
|
||||
val isKnockRequestsEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||
}.collectAsState(false)
|
||||
val knockRequestsCount by produceState<Int?>(null) {
|
||||
room.knockRequestsFlow.collect { value = it.size }
|
||||
}
|
||||
|
|
@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
|
||||
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
||||
|
||||
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
fun handleEvents(event: RoomDetailsEvent) {
|
||||
when (event) {
|
||||
RoomDetailsEvent.LeaveRoom ->
|
||||
|
|
@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
|
||||
is RoomDetailsEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
snackbarMessage = snackbarMessage,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
|
@ -42,6 +43,7 @@ data class RoomDetailsState(
|
|||
val canShowPinnedMessages: Boolean,
|
||||
val canShowMediaGallery: Boolean,
|
||||
val pinnedMessagesCount: Int?,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val canShowKnockRequests: Boolean,
|
||||
val knockRequestsCount: Int?,
|
||||
val canShowSecurityAndPrivacy: Boolean,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState
|
|||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -111,6 +112,7 @@ fun aRoomDetailsState(
|
|||
canShowPinnedMessages: Boolean = true,
|
||||
canShowMediaGallery: Boolean = true,
|
||||
pinnedMessagesCount: Int? = null,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
canShowKnockRequests: Boolean = false,
|
||||
knockRequestsCount: Int? = null,
|
||||
canShowSecurityAndPrivacy: Boolean = true,
|
||||
|
|
@ -139,11 +141,12 @@ fun aRoomDetailsState(
|
|||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
snackbarMessage = snackbarMessage,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
|
||||
hasMemberVerificationViolations = hasMemberVerificationViolations,
|
||||
eventSink = eventSink
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aRoomNotificationSettings(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
|
|||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
|
|
@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
|||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -106,6 +109,7 @@ fun RoomDetailsView(
|
|||
onProfileClick: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
|
|
@ -115,6 +119,7 @@ fun RoomDetailsView(
|
|||
onActionClick = onActionClick
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -135,6 +140,9 @@ fun RoomDetailsView(
|
|||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(state.roomName, avatarUrl)
|
||||
},
|
||||
onSubtitleClick = { subtitle ->
|
||||
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||
}
|
||||
)
|
||||
}
|
||||
is RoomDetailsType.Dm -> {
|
||||
|
|
@ -145,6 +153,9 @@ fun RoomDetailsView(
|
|||
openAvatarPreview = { name, avatarUrl ->
|
||||
openAvatarPreview(name, avatarUrl)
|
||||
},
|
||||
onSubtitleClick = { subtitle ->
|
||||
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -368,6 +379,7 @@ private fun RoomHeaderSection(
|
|||
roomAlias: RoomAlias?,
|
||||
heroes: ImmutableList<MatrixUser>,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -384,7 +396,11 @@ private fun RoomHeaderSection(
|
|||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.testTag(TestTags.roomDetailAvatar)
|
||||
)
|
||||
TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
|
||||
TitleAndSubtitle(
|
||||
title = roomName,
|
||||
subtitle = roomAlias?.value,
|
||||
onSubtitleClick = onSubtitleClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +410,7 @@ private fun DmHeaderSection(
|
|||
otherMember: RoomMember,
|
||||
roomName: String,
|
||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -411,6 +428,7 @@ private fun DmHeaderSection(
|
|||
TitleAndSubtitle(
|
||||
title = roomName,
|
||||
subtitle = otherMember.userId.value,
|
||||
onSubtitleClick = onSubtitleClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -419,6 +437,7 @@ private fun DmHeaderSection(
|
|||
private fun TitleAndSubtitle(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
onSubtitleClick: (String) -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
|
@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
|
|||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
|
|
@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
|
|||
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
|
||||
trailingContent =
|
||||
if (pinnedMessagesCount == null) {
|
||||
ListItemContent.Custom {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||
},
|
||||
if (pinnedMessagesCount == null) {
|
||||
ListItemContent.Custom {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||
},
|
||||
onClick = {
|
||||
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
|
||||
onPinnedMessagesClick()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
|
|
@ -25,6 +26,7 @@ object RoomMemberModule {
|
|||
room: MatrixRoom,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
encryptionService: EncryptionService,
|
||||
clipboardHelper: ClipboardHelper,
|
||||
): RoomMemberDetailsPresenter.Factory {
|
||||
return object : RoomMemberDetailsPresenter.Factory {
|
||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||
|
|
@ -33,6 +35,7 @@ object RoomMemberModule {
|
|||
room = room,
|
||||
userProfilePresenterFactory = userProfilePresenterFactory,
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents
|
|||
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
|
||||
import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
|
|
@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
|
@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
@Assisted private val roomMemberId: UserId,
|
||||
private val room: MatrixRoom,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
userProfilePresenterFactory: UserProfilePresenterFactory,
|
||||
) : Presenter<UserProfileState> {
|
||||
interface Factory {
|
||||
|
|
@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
override fun present(): UserProfileState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||
LaunchedEffect(Unit) {
|
||||
// Update room member info when opening this screen
|
||||
|
|
@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
|
||||
encryptionService.withdrawVerification(roomMemberId)
|
||||
}
|
||||
else -> Unit
|
||||
is UserProfileEvents.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
else -> userProfileState.eventSink(event)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
|||
userName = roomUserName ?: userProfileState.userName,
|
||||
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
|
||||
verificationState = verificationState,
|
||||
eventSink = { event ->
|
||||
if (event is UserProfileEvents.WithdrawVerification) {
|
||||
eventSink(UserProfileEvents.WithdrawVerification)
|
||||
} else {
|
||||
userProfileState.eventSink(event)
|
||||
}
|
||||
}
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
<item quantity="one">"%1$d osaleja"</item>
|
||||
<item quantity="other">"%1$d osalejat"</item>
|
||||
</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_remove">"Eemalda kasutaja jututoast"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eemalda ja sea suhtluskeeld"</string>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
<item quantity="one">"%1$d személy"</item>
|
||||
<item quantity="other">"%1$d személy"</item>
|
||||
</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_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>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@
|
|||
<item quantity="few">"%1$d osoby"</item>
|
||||
<item quantity="other">"%1$d osôb"</item>
|
||||
</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_remove">"Odstrániť z miestnosti"</string>
|
||||
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odstrániť a zakázať člena"</string>
|
||||
|
|
|
|||