Merge branch 'develop' into feature/fga/room_navigation
This commit is contained in:
2
.github/workflows/generate_github_pages.yml
vendored
2
.github/workflows/generate_github_pages.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
mkdir -p screenshots/en
|
||||
cp tests/uitests/src/test/snapshots/images/* screenshots/en
|
||||
- name: Deploy GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./screenshots
|
||||
|
||||
@@ -6,4 +6,8 @@ appId: ${MAESTRO_APP_ID}
|
||||
id: "verification-recovery_key"
|
||||
- inputText: ${MAESTRO_RECOVERY_KEY}
|
||||
- hideKeyboard
|
||||
- tapOn: "Confirm"
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Device verified"
|
||||
timeout: 10000
|
||||
- tapOn: "Continue"
|
||||
|
||||
@@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm that it's you"
|
||||
timeout: 10000
|
||||
timeout: 20000
|
||||
|
||||
27
CHANGES.md
27
CHANGES.md
@@ -1,3 +1,30 @@
|
||||
Changes in Element X v0.4.8 (2024-04-10)
|
||||
========================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579))
|
||||
- Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580))
|
||||
- Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601))
|
||||
- Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650))
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612))
|
||||
- Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619))
|
||||
- Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625))
|
||||
- Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581))
|
||||
- Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593))
|
||||
- Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608))
|
||||
- Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634))
|
||||
- Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678))
|
||||
- Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684))
|
||||
|
||||
|
||||
Changes in Element X v0.4.7 (2024-03-26)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.x
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -31,7 +32,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.bumble.appyx.core.integration.NodeHost
|
||||
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||
@@ -60,7 +60,7 @@ class MainActivity : NodeActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
appBindings = bindings()
|
||||
appBindings.lockScreenService().handleSecureFlag(this)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MainContent(appBindings)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DaggerComponentOwner
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
@@ -58,6 +59,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val matrixClient: MatrixClient,
|
||||
roomComponentFactory: RoomComponentFactory,
|
||||
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -92,6 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
Timber.v("OnCreate => ${inputs.room.roomId}")
|
||||
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
|
||||
fetchRoomMembers()
|
||||
trackVisitedRoom()
|
||||
},
|
||||
onResume = {
|
||||
appCoroutineScope.launch {
|
||||
@@ -110,6 +113,10 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun trackVisitedRoom() = lifecycleScope.launch {
|
||||
matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
|
||||
}
|
||||
|
||||
private fun fetchRoomMembers() = lifecycleScope.launch {
|
||||
inputs.room.updateMembers()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.libraries.architecture.childNode
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -101,6 +102,7 @@ class RoomFlowNodeTest {
|
||||
roomMembershipObserver = RoomMembershipObserver(),
|
||||
appCoroutineScope = coroutineScope,
|
||||
roomComponentFactory = FakeRoomComponentFactory(),
|
||||
matrixClient = FakeMatrixClient(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Move session verification to the after login flow and make it mandatory.
|
||||
@@ -1 +0,0 @@
|
||||
Add a notification troubleshoot screen
|
||||
@@ -1 +0,0 @@
|
||||
Make completed poll more clearly visible
|
||||
@@ -1 +0,0 @@
|
||||
Fix analytics issue around room considered as space by mistake.
|
||||
@@ -1 +0,0 @@
|
||||
Fix crash observed when going back to the room list.
|
||||
@@ -1 +0,0 @@
|
||||
Hide Event org.matrix.msc3401.call.member on the timeline.
|
||||
@@ -1 +0,0 @@
|
||||
Add action to copy permalink
|
||||
2
fastlane/metadata/android/en-US/changelogs/40004080.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40004080.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: Enable room moderation feature.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
@@ -17,19 +17,14 @@
|
||||
package io.element.android.features.analytics.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Poll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -45,18 +40,18 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@@ -82,6 +77,7 @@ fun AnalyticsOptInView(
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
background = { OnboardingBackground() },
|
||||
header = { AnalyticsOptInHeader(state, onClickTerms) },
|
||||
content = { AnalyticsOptInContent() },
|
||||
footer = {
|
||||
@@ -103,11 +99,11 @@ private fun AnalyticsOptInHeader(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
|
||||
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
|
||||
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconImageVector = Icons.Filled.Poll
|
||||
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
|
||||
)
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
@@ -136,19 +132,6 @@ private fun AnalyticsOptInHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckIcon() {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
||||
.padding(2.dp),
|
||||
imageVector = CompoundIcons.Check(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsOptInContent() {
|
||||
Box(
|
||||
@@ -162,20 +145,20 @@ private fun AnalyticsOptInContent() {
|
||||
items = persistentListOf(
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
),
|
||||
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||
iconTint = ElementTheme.colors.textPrimary,
|
||||
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
|
||||
textStyle = ElementTheme.typography.fontBodyLgMedium,
|
||||
iconTint = ElementTheme.colors.iconSuccessPrimary,
|
||||
backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ dependencies {
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.features.createroom.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
@@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
||||
override val values: Sequence<UserListState>
|
||||
get() = sequenceOf(
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = false,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
searchResults = SearchBarResultState.Results(
|
||||
aMatrixUserList()
|
||||
.mapIndexed { index, matrixUser ->
|
||||
@@ -46,6 +47,9 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = true,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
)
|
||||
),
|
||||
aUserListState(
|
||||
recentDirectRooms = aRecentDirectRoomList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,8 @@
|
||||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -64,21 +62,16 @@ fun AddPeopleView(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
UserListView(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
) {
|
||||
UserListView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
state = state,
|
||||
showBackButton = false,
|
||||
onUserSelected = { },
|
||||
onUserDeselected = {},
|
||||
)
|
||||
}
|
||||
state = state,
|
||||
showBackButton = false,
|
||||
onUserSelected = {},
|
||||
onUserDeselected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,17 +19,27 @@ package io.element.android.features.createroom.impl.components
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun UserListView(
|
||||
@@ -74,6 +84,43 @@ fun UserListView(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!state.isSearchActive && state.recentDirectRooms.isNotEmpty()) {
|
||||
LazyColumn {
|
||||
item {
|
||||
ListSectionHeader(
|
||||
title = stringResource(id = CommonStrings.common_suggestions),
|
||||
hasDivider = false,
|
||||
)
|
||||
}
|
||||
state.recentDirectRooms.forEachIndexed { index, recentDirectRoom ->
|
||||
item {
|
||||
val isSelected = state.selectedUsers.any {
|
||||
recentDirectRoom.matrixUser.userId == it.userId
|
||||
}
|
||||
CheckableUserRow(
|
||||
checked = isSelected,
|
||||
onCheckedChange = {
|
||||
if (isSelected) {
|
||||
state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser))
|
||||
onUserDeselected(recentDirectRoom.matrixUser)
|
||||
} else {
|
||||
state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser))
|
||||
onUserSelected(recentDirectRoom.matrixUser)
|
||||
}
|
||||
},
|
||||
data = CheckableUserRowData.Resolved(
|
||||
avatarData = recentDirectRoom.matrixUser.getAvatarData(AvatarSize.UserListItem),
|
||||
name = recentDirectRoom.matrixUser.getBestName(),
|
||||
subtext = recentDirectRoom.matrixUser.userId.value,
|
||||
),
|
||||
)
|
||||
if (index < state.recentDirectRooms.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -28,7 +31,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||
override val values: Sequence<CreateRoomRootState>
|
||||
get() = sequenceOf(
|
||||
aCreateRoomRootState(),
|
||||
aCreateRoomRootState().copy(
|
||||
aCreateRoomRootState(
|
||||
startDmAction = AsyncAction.Loading,
|
||||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
@@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||
)
|
||||
}
|
||||
),
|
||||
aCreateRoomRootState().copy(
|
||||
aCreateRoomRootState(
|
||||
startDmAction = AsyncAction.Failure(Throwable("error")),
|
||||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
@@ -50,12 +53,22 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||
)
|
||||
}
|
||||
),
|
||||
aCreateRoomRootState(
|
||||
userListState = aUserListState(
|
||||
recentDirectRooms = aRecentDirectRoomList()
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aCreateRoomRootState() = CreateRoomRootState(
|
||||
eventSink = {},
|
||||
applicationName = "Element X Preview",
|
||||
startDmAction = AsyncAction.Uninitialized,
|
||||
userListState = aUserListState(),
|
||||
fun aCreateRoomRootState(
|
||||
applicationName: String = "Element X Preview",
|
||||
userListState: UserListState = aUserListState(),
|
||||
startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
eventSink: (CreateRoomRootEvents) -> Unit = {},
|
||||
) = CreateRoomRootState(
|
||||
applicationName = applicationName,
|
||||
userListState = userListState,
|
||||
startDmAction = startDmAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -46,11 +47,14 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||
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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun CreateRoomRootView(
|
||||
@@ -77,7 +81,11 @@ fun CreateRoomRootView(
|
||||
) {
|
||||
UserListView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = state.userListState,
|
||||
// Do not render suggestions in this case, the suggestion will be rendered
|
||||
// by CreateRoomActionButtonsList
|
||||
state = state.userListState.copy(
|
||||
recentDirectRooms = persistentListOf(),
|
||||
),
|
||||
onUserSelected = {
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(it))
|
||||
},
|
||||
@@ -89,6 +97,7 @@ fun CreateRoomRootView(
|
||||
state = state,
|
||||
onNewRoomClicked = onNewRoomClicked,
|
||||
onInvitePeopleClicked = onInviteFriendsClicked,
|
||||
onDmClicked = onOpenDM,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +115,7 @@ fun CreateRoomRootView(
|
||||
onRetry = {
|
||||
state.userListState.selectedUsers.firstOrNull()
|
||||
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
|
||||
// Cancel start DM if there is no more selected user (should not happen)
|
||||
// Cancel start DM if there is no more selected user (should not happen)
|
||||
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
},
|
||||
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
||||
@@ -139,18 +148,43 @@ private fun CreateRoomActionButtonsList(
|
||||
state: CreateRoomRootState,
|
||||
onNewRoomClicked: () -> Unit,
|
||||
onInvitePeopleClicked: () -> Unit,
|
||||
onDmClicked: (RoomId) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_plus,
|
||||
text = stringResource(id = R.string.screen_create_room_action_create_room),
|
||||
onClick = onNewRoomClicked,
|
||||
)
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_share_android,
|
||||
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
|
||||
onClick = onInvitePeopleClicked,
|
||||
)
|
||||
LazyColumn {
|
||||
item {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_plus,
|
||||
text = stringResource(id = R.string.screen_create_room_action_create_room),
|
||||
onClick = onNewRoomClicked,
|
||||
)
|
||||
}
|
||||
item {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_share_android,
|
||||
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
|
||||
onClick = onInvitePeopleClicked,
|
||||
)
|
||||
}
|
||||
if (state.userListState.recentDirectRooms.isNotEmpty()) {
|
||||
item {
|
||||
ListSectionHeader(
|
||||
title = stringResource(id = CommonStrings.common_suggestions),
|
||||
hasDivider = false,
|
||||
)
|
||||
}
|
||||
state.userListState.recentDirectRooms.forEach { recentDirectRoom ->
|
||||
item {
|
||||
MatrixUserRow(
|
||||
modifier = Modifier.clickable(
|
||||
onClick = {
|
||||
onDmClicked(recentDirectRoom.roomId)
|
||||
}
|
||||
),
|
||||
matrixUser = recentDirectRoom.matrixUser,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
|
||||
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
|
||||
import io.element.android.libraries.usersearch.api.UserRepository
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -41,6 +44,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||
@Assisted val args: UserListPresenterArgs,
|
||||
@Assisted val userRepository: UserRepository,
|
||||
@Assisted val userListDataStore: UserListDataStore,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : UserListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@@ -54,6 +58,10 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||
|
||||
@Composable
|
||||
override fun present(): UserListState {
|
||||
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
@@ -82,6 +90,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||
isSearchActive = isSearchActive,
|
||||
showSearchLoader = showSearchLoader,
|
||||
selectionMode = args.selectionMode,
|
||||
recentDirectRooms = recentDirectRooms.toImmutableList(),
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -28,6 +29,7 @@ data class UserListState(
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val isSearchActive: Boolean,
|
||||
val selectionMode: SelectionMode,
|
||||
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
|
||||
val eventSink: (UserListEvents) -> Unit,
|
||||
) {
|
||||
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
|
||||
|
||||
@@ -18,54 +18,82 @@ package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
||||
override val values: Sequence<UserListState>
|
||||
get() = sequenceOf(
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = false,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aUserListState().copy(isSearchActive = true),
|
||||
aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
|
||||
aUserListState().copy(
|
||||
aUserListState(isSearchActive = true),
|
||||
aUserListState(isSearchActive = true, searchQuery = "someone"),
|
||||
aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "something-with-no-results",
|
||||
searchResults = SearchBarResultState.NoResultsFound()
|
||||
),
|
||||
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Single),
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "someone",
|
||||
selectionMode = SelectionMode.Single,
|
||||
),
|
||||
aUserListState(
|
||||
recentDirectRooms = aRecentDirectRoomList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aUserListState() = UserListState(
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchResults = SearchBarResultState.Initial(),
|
||||
selectedUsers = persistentListOf(),
|
||||
selectionMode = SelectionMode.Single,
|
||||
showSearchLoader = false,
|
||||
eventSink = {}
|
||||
fun aUserListState(
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
|
||||
selectedUsers: List<MatrixUser> = emptyList(),
|
||||
showSearchLoader: Boolean = false,
|
||||
selectionMode: SelectionMode = SelectionMode.Single,
|
||||
recentDirectRooms: List<RecentDirectRoom> = emptyList(),
|
||||
eventSink: (UserListEvents) -> Unit = {},
|
||||
) = UserListState(
|
||||
searchQuery = searchQuery,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
showSearchLoader = showSearchLoader,
|
||||
selectionMode = selectionMode,
|
||||
recentDirectRooms = recentDirectRooms.toImmutableList(),
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
|
||||
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()
|
||||
|
||||
fun aRecentDirectRoomList(
|
||||
count: Int = 5
|
||||
): List<RecentDirectRoom> = aMatrixUserList()
|
||||
.take(count)
|
||||
.map {
|
||||
RecentDirectRoom(RoomId("!aRoom:id"), it)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.addpeople
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AddPeopleViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<UserListEvents>()
|
||||
ensureCalledOnce {
|
||||
rule.setAddPeopleView(
|
||||
aUserListState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onBackPressed = it
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on back during search emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<UserListEvents>()
|
||||
rule.setAddPeopleView(
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.pressBack()
|
||||
eventsRecorder.assertSingle(UserListEvents.OnSearchActiveChanged(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on skip invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<UserListEvents>()
|
||||
ensureCalledOnce {
|
||||
rule.setAddPeopleView(
|
||||
aUserListState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onNextPressed = it
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_skip)
|
||||
}
|
||||
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddPeopleView(
|
||||
state: UserListState,
|
||||
onBackPressed: () -> Unit = EnsureNeverCalled(),
|
||||
onNextPressed: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
AddPeopleView(
|
||||
state = state,
|
||||
onBackPressed = onBackPressed,
|
||||
onNextPressed = onNextPressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
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.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CreateRoomRootViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setCreateRoomRootView(
|
||||
aCreateRoomRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onClosePressed = it
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on New room invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setCreateRoomRootView(
|
||||
aCreateRoomRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onNewRoomClicked = it
|
||||
)
|
||||
rule.clickOn(R.string.screen_create_room_action_create_room)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on Invite people invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setCreateRoomRootView(
|
||||
aCreateRoomRootState(
|
||||
applicationName = "test",
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onInviteFriendsClicked = it
|
||||
)
|
||||
val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
|
||||
rule.onNodeWithText(text).performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on a user suggestion invokes the expected callback`() {
|
||||
val recentDirectRoomList = aRecentDirectRoomList()
|
||||
val firstRoom = recentDirectRoomList[0]
|
||||
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
|
||||
ensureCalledOnceWithParam(firstRoom.roomId) {
|
||||
rule.setCreateRoomRootView(
|
||||
aCreateRoomRootState(
|
||||
userListState = aUserListState(
|
||||
recentDirectRooms = recentDirectRoomList
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenDM = it
|
||||
)
|
||||
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
|
||||
state: CreateRoomRootState,
|
||||
onClosePressed: () -> Unit = EnsureNeverCalled(),
|
||||
onNewRoomClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onInviteFriendsClicked: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
CreateRoomRootView(
|
||||
state = state,
|
||||
onClosePressed = onClosePressed,
|
||||
onNewRoomClicked = onNewRoomClicked,
|
||||
onOpenDM = onOpenDM,
|
||||
onInviteFriendsClicked = onInviteFriendsClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
@@ -45,6 +46,7 @@ class DefaultUserListPresenterTests {
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -66,6 +68,7 @@ class DefaultUserListPresenterTests {
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -87,6 +90,7 @@ class DefaultUserListPresenterTests {
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -123,6 +127,7 @@ class DefaultUserListPresenterTests {
|
||||
),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -175,6 +180,7 @@ class DefaultUserListPresenterTests {
|
||||
),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -200,6 +206,7 @@ class DefaultUserListPresenterTests {
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -40,8 +40,10 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
@@ -62,8 +64,9 @@ fun NotificationsOptInView(
|
||||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.systemBarsPadding()
|
||||
.statusBarsPadding()
|
||||
.fillMaxSize(),
|
||||
background = { OnboardingBackground() },
|
||||
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
|
||||
footer = { NotificationsOptInFooter(state) },
|
||||
) {
|
||||
@@ -75,11 +78,11 @@ fun NotificationsOptInView(
|
||||
private fun NotificationsOptInHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
PageTitle(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.screen_notification_optin_title),
|
||||
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
|
||||
iconImageVector = CompoundIcons.NotificationsSolid(),
|
||||
subtitle = stringResource(R.string.screen_notification_optin_subtitle),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ package io.element.android.features.ftue.impl.sessionverification
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -33,6 +35,7 @@ import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -55,13 +58,27 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : NavTarget
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
private val callback = plugins<Callback>().first()
|
||||
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
|
||||
override fun onCreateNewRecoveryKey() {
|
||||
backstack.push(NavTarget.CreateNewRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch {
|
||||
// Move to the completed state view in the verification flow
|
||||
backstack.newRoot(NavTarget.Root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
@@ -72,8 +89,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.EnterRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onCreateNewRecoveryKey() {
|
||||
backstack.push(NavTarget.CreateNewRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onDone() {
|
||||
callback.onDone()
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
})
|
||||
.build()
|
||||
@@ -81,11 +102,13 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
is NavTarget.EnterRecoveryKey -> {
|
||||
secureBackupEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
|
||||
.callback(object : SecureBackupEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
callback.onDone()
|
||||
}
|
||||
})
|
||||
.callback(secureBackupEntryPointCallback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.CreateNewRecoveryKey -> {
|
||||
secureBackupEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
|
||||
.callback(secureBackupEntryPointCallback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
@@ -56,7 +55,7 @@ class DefaultFtueService @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
sessionVerificationService.sessionVerifiedStatus
|
||||
sessionVerificationService.needsVerificationFlow
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
@@ -99,12 +98,8 @@ class DefaultFtueService @Inject constructor(
|
||||
).any { it() }
|
||||
}
|
||||
|
||||
private fun isSessionVerificationServiceReady(): Boolean {
|
||||
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
|
||||
}
|
||||
|
||||
private fun isSessionNotVerified(): Boolean {
|
||||
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified
|
||||
return sessionVerificationService.needsVerificationFlow.value
|
||||
}
|
||||
|
||||
private fun needsAnalyticsOptIn(): Boolean {
|
||||
@@ -132,7 +127,6 @@ class DefaultFtueService @Inject constructor(
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun updateState() {
|
||||
state.value = when {
|
||||
!isSessionVerificationServiceReady() -> FtueState.Unknown
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
|
||||
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце Element на настольнай прыладзе"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4">"Выберыце %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4_action">"“Паказаць QR-код”"</string>
|
||||
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце Element на іншай прыладзе, каб атрымаць QR-код"</string>
|
||||
<string name="screen_welcome_bullet_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string>
|
||||
<string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
|
||||
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Откройте Element на настольном устройстве"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4">"Выбрать %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4_action">"\"Показать QR-код\""</string>
|
||||
<string name="screen_qr_code_login_initial_state_title">"Откройте Element на другом устройстве, чтобы получить QR-код"</string>
|
||||
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
|
||||
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
|
||||
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Open Element on a desktop device"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4">"Select %1$s"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_4_action">"“Show QR code”"</string>
|
||||
<string name="screen_qr_code_login_initial_state_title">"Open Element on another device to get the QR code"</string>
|
||||
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isn’t available yet."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||
|
||||
@@ -90,6 +90,7 @@ class DefaultFtueServiceTests {
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
givenNeedsVerification(true)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
@@ -107,7 +108,7 @@ class DefaultFtueServiceTests {
|
||||
|
||||
// Session verification
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.givenNeedsVerification(false)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -166,6 +167,7 @@ internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
|
||||
)
|
||||
}
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlin.math.ceil
|
||||
|
||||
// Allow to not overlap the timestamp with the text, in the message bubble.
|
||||
// Compute the size of the worst case.
|
||||
data class ExtraPadding(val extraWidth: Dp)
|
||||
|
||||
val noExtraPadding = ExtraPadding(0.dp)
|
||||
|
||||
/**
|
||||
* See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
|
||||
* And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
||||
val formattedTime = sentTime
|
||||
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
|
||||
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 128)
|
||||
val density = LocalDensity.current
|
||||
|
||||
var strLen = 2.dp // Extra space char
|
||||
if (isMessageEdited) {
|
||||
val editedText = stringResource(id = CommonStrings.common_edited_suffix)
|
||||
val extraLen = remember(editedText, density) { textMeasurer.getExtraPadding(editedText, density) } + 10.dp // Text + spacing
|
||||
strLen += extraLen
|
||||
}
|
||||
strLen += remember(formattedTime, density) { textMeasurer.getExtraPadding(formattedTime, density) }
|
||||
if (hasMessageSendingFailed) {
|
||||
strLen += 19.dp // Image + spacing
|
||||
// I do not know why, but adding extra widths avoid overlapping when the
|
||||
// message is edited and in error.
|
||||
if (isMessageEdited) {
|
||||
strLen += 2.dp
|
||||
}
|
||||
}
|
||||
return ExtraPadding(strLen)
|
||||
}
|
||||
|
||||
private fun TextMeasurer.getExtraPadding(text: String, density: Density): Dp {
|
||||
val timestampTextStyle = ElementTheme.typography.fontBodyXsRegular
|
||||
val textWidth = measure(text = text, style = timestampTextStyle).size.width
|
||||
return (textWidth / density.density).dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a string to add to the content of the message to avoid overlapping the timestamp.
|
||||
*/
|
||||
@Composable
|
||||
fun ExtraPadding.getStr(textStyle: TextStyle = LocalTextStyle.current): String {
|
||||
if (extraWidth == 0.dp) return ""
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer(128)
|
||||
val charWidth = remember(textStyle) { textMeasurer.measure(text = "\u00A0", style = textStyle).size.width }
|
||||
val widthPx = remember(density, extraWidth) { with(density) { extraWidth.toPx() } }
|
||||
// A space and some unbreakable spaces, always rounding the result to the next value if not a integer
|
||||
return " " + "\u00A0".repeat(ceil(widthPx / charWidth).toInt())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtraPadding.getDpSize(): Dp {
|
||||
return extraWidth
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface RetrySendMenuEvents {
|
||||
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
|
||||
data object RetrySend : RetrySendMenuEvents
|
||||
data object RemoveFailed : RetrySendMenuEvents
|
||||
data object Retry : RetrySendMenuEvents
|
||||
data object Remove : RetrySendMenuEvents
|
||||
data object Dismiss : RetrySendMenuEvents
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
||||
is RetrySendMenuEvents.EventSelected -> {
|
||||
selectedEvent = event.event
|
||||
}
|
||||
RetrySendMenuEvents.RetrySend -> {
|
||||
RetrySendMenuEvents.Retry -> {
|
||||
coroutineScope.launch {
|
||||
selectedEvent?.transactionId?.let { transactionId ->
|
||||
room.retrySendMessage(transactionId)
|
||||
@@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
||||
selectedEvent = null
|
||||
}
|
||||
}
|
||||
RetrySendMenuEvents.RemoveFailed -> {
|
||||
RetrySendMenuEvents.Remove -> {
|
||||
coroutineScope.launch {
|
||||
selectedEvent?.transactionId?.let { transactionId ->
|
||||
room.cancelSend(transactionId)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
|
||||
}
|
||||
|
||||
fun onRetry() {
|
||||
state.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
state.eventSink(RetrySendMenuEvents.Retry)
|
||||
}
|
||||
|
||||
fun onRemoveFailed() {
|
||||
state.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
fun onRemove() {
|
||||
state.eventSink(RetrySendMenuEvents.Remove)
|
||||
}
|
||||
|
||||
RetrySendMessageMenuBottomSheet(
|
||||
modifier = modifier,
|
||||
isVisible = isVisible,
|
||||
onRetry = ::onRetry,
|
||||
onRemoveFailed = ::onRemoveFailed,
|
||||
onRemove = ::onRemove,
|
||||
onDismiss = ::onDismiss
|
||||
)
|
||||
}
|
||||
@@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
|
||||
private fun RetrySendMessageMenuBottomSheet(
|
||||
isVisible: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onRemoveFailed: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -95,7 +94,10 @@ private fun RetrySendMessageMenuBottomSheet(
|
||||
}
|
||||
}
|
||||
) {
|
||||
RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed)
|
||||
RetrySendMenuContents(
|
||||
onRetry = onRetry,
|
||||
onRemove = onRemove,
|
||||
)
|
||||
// FIXME remove after https://issuetracker.google.com/issues/275849044
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
@@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
|
||||
@Composable
|
||||
private fun ColumnScope.RetrySendMenuContents(
|
||||
onRetry: () -> Unit,
|
||||
onRemoveFailed: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
|
||||
modifier = Modifier.clickable {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
onRemoveFailed()
|
||||
onRemove()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
|
||||
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
|
||||
Column {
|
||||
RetrySendMenuContents(
|
||||
onRetry = {},
|
||||
onRemoveFailed = {},
|
||||
)
|
||||
}
|
||||
RetrySendMessageMenu(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
val selectedEvent = aTimelineItemEvent()
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
|
||||
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent()
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.Dismiss)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(1)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
@@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = null)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
@@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(1)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
@@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(1)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = null)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
|
||||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(1)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RetrySendMessageMenuTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `dismiss the bottom sheet emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
|
||||
rule.setRetrySendMessageMenu(
|
||||
aRetrySendMenuState(
|
||||
event = aTimelineItemEvent(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
// Cannot test this for now.
|
||||
// eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `retry to send the event emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
|
||||
rule.setRetrySendMessageMenu(
|
||||
aRetrySendMenuState(
|
||||
event = aTimelineItemEvent(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
|
||||
eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `remove the event emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
|
||||
rule.setRetrySendMessageMenu(
|
||||
aRetrySendMenuState(
|
||||
event = aTimelineItemEvent(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_remove)
|
||||
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
|
||||
state: RetrySendMenuState,
|
||||
) {
|
||||
setContent {
|
||||
RetrySendMessageMenu(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
@@ -212,6 +213,7 @@ internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvide
|
||||
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
|
||||
ElementPreviewDark { ContentToPreview(matrixUser) }
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||
PreferencesRootView(
|
||||
|
||||
@@ -78,10 +78,6 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
|
||||
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
|
||||
|
||||
val isRoomModerationEnabled by produceState(initialValue = false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
|
||||
if (canShowNotificationSettings.value) {
|
||||
@@ -147,7 +143,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
leaveRoomState = leaveRoomState,
|
||||
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
|
||||
isFavorite = isFavorite,
|
||||
displayRolesAndPermissionsSettings = isRoomModerationEnabled && !room.isDm && isUserAdmin,
|
||||
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.RoomMembe
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
@@ -50,7 +48,6 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
|
||||
@Assisted private val navigator: RoomMemberListNavigator,
|
||||
) : Presenter<RoomMemberListState> {
|
||||
@@ -74,15 +71,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
|
||||
value = room.canInvite().getOrElse { false }
|
||||
}
|
||||
|
||||
val isRoomModerationEnabled by produceState(initialValue = false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
|
||||
}
|
||||
|
||||
val roomModerationState = if (isRoomModerationEnabled) {
|
||||
roomMembersModerationPresenter.present()
|
||||
} else {
|
||||
remember { roomMembersModerationPresenter.dummyState() }
|
||||
}
|
||||
val roomModerationState = roomMembersModerationPresenter.present()
|
||||
|
||||
// Ensure we load the latest data when entering this screen
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
@@ -127,6 +128,7 @@ internal fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetai
|
||||
internal fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(state: RoomMemberDetailsState) {
|
||||
RoomMemberDetailsView(
|
||||
|
||||
@@ -31,8 +31,6 @@ import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.finally
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
@@ -51,7 +49,6 @@ import javax.inject.Inject
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultRoomMembersModerationPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : RoomMembersModerationPresenter {
|
||||
@@ -61,9 +58,8 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
|
||||
private suspend fun canKick() = room.canKick().getOrDefault(false)
|
||||
|
||||
override suspend fun canDisplayModerationActions(): Boolean {
|
||||
val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
|
||||
val isDm = room.isDm && room.isEncrypted
|
||||
return isRoomModerationEnabled && !isDm && (canBan() || canKick())
|
||||
return !isDm && (canBan() || canKick())
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -76,7 +72,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
|
||||
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
|
||||
|
||||
val canDisplayBannedUsers by produceState(initialValue = false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
|
||||
value = !room.isDm && canBan()
|
||||
}
|
||||
|
||||
fun handleEvent(event: RoomMembersModerationEvents) {
|
||||
|
||||
@@ -29,9 +29,11 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -47,14 +49,22 @@ class RolesAndPermissionsPresenter @Inject constructor(
|
||||
override fun present(): RolesAndPermissionsState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
val roomMembers by room.membersStateFlow.collectAsState()
|
||||
// Get the list of joined room members, in order to filter members present in the power
|
||||
// level state Event, but not member of the room anymore.
|
||||
val joinedRoomMemberIds by remember {
|
||||
derivedStateOf {
|
||||
roomMembers.joinedRoomMembers().map { it.userId }
|
||||
}
|
||||
}
|
||||
val moderatorCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(RoomMember.Role.MODERATOR)
|
||||
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.MODERATOR)
|
||||
}
|
||||
}
|
||||
val adminCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(RoomMember.Role.ADMIN)
|
||||
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.ADMIN)
|
||||
}
|
||||
}
|
||||
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
@@ -108,11 +118,9 @@ class RolesAndPermissionsPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int {
|
||||
return if (this != null) {
|
||||
userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role }
|
||||
} else {
|
||||
0
|
||||
private fun MatrixRoomInfo?.userCountWithRole(joinedRoomMemberIds: List<UserId>, role: RoomMember.Role): Int {
|
||||
return this?.userPowerLevels.orEmpty().count { (userId, level) ->
|
||||
RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface ChangeRolesEvent {
|
||||
data object ToggleSearchActive : ChangeRolesEvent
|
||||
data class QueryChanged(val query: String?) : ChangeRolesEvent
|
||||
data class UserSelectionToggled(val roomMember: RoomMember) : ChangeRolesEvent
|
||||
data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
|
||||
data object Save : ChangeRolesEvent
|
||||
data object Exit : ChangeRolesEvent
|
||||
data object CancelExit : ChangeRolesEvent
|
||||
|
||||
@@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
|
||||
ChangeRolesView(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onBackPressed = this::navigateUp,
|
||||
navigateUp = this::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -73,7 +75,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||
var query by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var searchResults by remember {
|
||||
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
|
||||
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
|
||||
}
|
||||
val selectedUsers = remember {
|
||||
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
|
||||
@@ -89,7 +91,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||
// Users who were selected but didn't have the role, so their role change was pending
|
||||
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
|
||||
// Users who no longer have the role
|
||||
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }
|
||||
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
|
||||
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
|
||||
}
|
||||
.launchIn(this)
|
||||
@@ -101,8 +103,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||
LaunchedEffect(query, roomMemberState) {
|
||||
val results = dataSource
|
||||
.search(query.orEmpty())
|
||||
.sorted()
|
||||
.groupedByRole()
|
||||
|
||||
println(results)
|
||||
searchResults = if (results.isEmpty()) {
|
||||
SearchBarResultState.NoResultsFound()
|
||||
} else {
|
||||
@@ -129,11 +132,11 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||
}
|
||||
is ChangeRolesEvent.UserSelectionToggled -> {
|
||||
val newList = selectedUsers.value.toMutableList()
|
||||
val index = newList.indexOfFirst { it.userId == event.roomMember.userId }
|
||||
val index = newList.indexOfFirst { it.userId == event.matrixUser.userId }
|
||||
if (index >= 0) {
|
||||
newList.removeAt(index)
|
||||
} else {
|
||||
newList.add(event.roomMember.toMatrixUser())
|
||||
newList.add(event.matrixUser)
|
||||
}
|
||||
selectedUsers.value = newList.toImmutableList()
|
||||
}
|
||||
@@ -179,16 +182,18 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<RoomMember>.groupedByRole(): MembersByRole {
|
||||
return MembersByRole(
|
||||
admins = filter { it.role == RoomMember.Role.ADMIN }.sorted(),
|
||||
moderators = filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
|
||||
members = filter { it.role == RoomMember.Role.USER }.sorted(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||
}
|
||||
|
||||
private fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
private fun CoroutineScope.save(
|
||||
usersWithRole: ImmutableList<MatrixUser>,
|
||||
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
|
||||
|
||||
@@ -16,18 +16,20 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
|
||||
|
||||
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class ChangeRolesState(
|
||||
val role: RoomMember.Role,
|
||||
val query: String?,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
|
||||
val searchResults: SearchBarResultState<MembersByRole>,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val hasPendingChanges: Boolean,
|
||||
val exitState: AsyncAction<Unit>,
|
||||
@@ -35,3 +37,21 @@ data class ChangeRolesState(
|
||||
val canChangeMemberRole: (UserId) -> Boolean,
|
||||
val eventSink: (ChangeRolesEvent) -> Unit,
|
||||
)
|
||||
|
||||
data class MembersByRole(
|
||||
val admins: ImmutableList<RoomMember>,
|
||||
val moderators: ImmutableList<RoomMember>,
|
||||
val members: ImmutableList<RoomMember>,
|
||||
) {
|
||||
constructor(members: List<RoomMember>) : this(
|
||||
admins = members.filter { it.role == RoomMember.Role.ADMIN }.sorted(),
|
||||
moderators = members.filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
|
||||
members = members.filter { it.role == RoomMember.Role.USER }.sorted(),
|
||||
)
|
||||
|
||||
fun isEmpty() = admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
|
||||
}
|
||||
|
||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -32,7 +33,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||
override val values: Sequence<ChangeRolesState>
|
||||
get() = sequenceOf(
|
||||
aChangeRolesState(),
|
||||
aChangeRolesState(role = RoomMember.Role.MODERATOR),
|
||||
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
|
||||
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
|
||||
aChangeRolesStateWithSelectedUsers(),
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
@@ -41,7 +42,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
query = "Alice",
|
||||
isSearchActive = true,
|
||||
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
|
||||
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
|
||||
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
|
||||
),
|
||||
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
|
||||
@@ -56,12 +57,13 @@ internal fun aChangeRolesState(
|
||||
role: RoomMember.Role = RoomMember.Role.ADMIN,
|
||||
query: String? = null,
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
|
||||
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
|
||||
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
hasPendingChanges: Boolean = false,
|
||||
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
canRemoveMember: (UserId) -> Boolean = { true },
|
||||
eventSink: (ChangeRolesEvent) -> Unit = {},
|
||||
) = ChangeRolesState(
|
||||
role = role,
|
||||
query = query,
|
||||
@@ -72,12 +74,22 @@ internal fun aChangeRolesState(
|
||||
exitState = exitState,
|
||||
savingState = savingState,
|
||||
canChangeMemberRole = canRemoveMember,
|
||||
eventSink = {},
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
|
||||
searchResults = SearchBarResultState.Results(
|
||||
MembersByRole(
|
||||
members = aRoomMemberList().mapIndexed { index, roomMember ->
|
||||
if (index % 2 == 0) {
|
||||
roomMember.copy(membership = RoomMembershipState.INVITE)
|
||||
} else {
|
||||
roomMember
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
hasPendingChanges = true,
|
||||
canRemoveMember = { it != UserId("@alice:server.org") },
|
||||
)
|
||||
|
||||
@@ -26,8 +26,10 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
@@ -36,23 +38,29 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
@@ -68,27 +76,24 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangeRolesView(
|
||||
state: ChangeRolesState,
|
||||
onBackPressed: () -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
|
||||
BackHandler {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
} else {
|
||||
state.eventSink(ChangeRolesEvent.Exit)
|
||||
}
|
||||
val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp)
|
||||
BackHandler(enabled = !state.isSearchActive) {
|
||||
state.eventSink(ChangeRolesEvent.Exit)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -129,7 +134,9 @@ fun ChangeRolesView(
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
|
||||
query = state.query.orEmpty(),
|
||||
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
|
||||
@@ -138,12 +145,12 @@ fun ChangeRolesView(
|
||||
resultState = state.searchResults,
|
||||
) { members ->
|
||||
SearchResultsList(
|
||||
isSearchActive = true,
|
||||
currentRole = state.role,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = members,
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
||||
selectedUsersList = {},
|
||||
)
|
||||
}
|
||||
@@ -154,18 +161,18 @@ fun ChangeRolesView(
|
||||
) {
|
||||
Column {
|
||||
SearchResultsList(
|
||||
isSearchActive = false,
|
||||
currentRole = state.role,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
|
||||
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
||||
selectedUsersList = { users ->
|
||||
SelectedUsersRowList(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
selectedUsers = users,
|
||||
onUserRemoved = {
|
||||
state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId)))
|
||||
state.eventSink(ChangeRolesEvent.UserSelectionToggled(it))
|
||||
},
|
||||
canDeselect = { state.canChangeMemberRole(it.userId) },
|
||||
)
|
||||
@@ -181,7 +188,7 @@ fun ChangeRolesView(
|
||||
|
||||
AsyncActionView(
|
||||
async = state.exitState,
|
||||
onSuccess = { updatedOnBackPressed() },
|
||||
onSuccess = { updatedNavigateUp() },
|
||||
confirmationDialog = {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
|
||||
@@ -229,8 +236,8 @@ fun ChangeRolesView(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResultsList(
|
||||
isSearchActive: Boolean,
|
||||
searchResults: ImmutableList<RoomMember>,
|
||||
currentRole: RoomMember.Role,
|
||||
searchResults: MembersByRole,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
canRemoveMember: (UserId) -> Boolean,
|
||||
onSelectionToggled: (RoomMember) -> Unit,
|
||||
@@ -243,43 +250,145 @@ private fun SearchResultsList(
|
||||
item {
|
||||
selectedUsersList(selectedUsers)
|
||||
}
|
||||
stickyHeader {
|
||||
val textResId = if (isSearchActive) {
|
||||
CommonStrings.common_search_results
|
||||
} else {
|
||||
R.string.screen_room_member_list_room_members_header_title
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
text = stringResource(textResId),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
}
|
||||
items(searchResults, key = { it.userId }) { roomMember ->
|
||||
val canToggle = canRemoveMember(roomMember.userId)
|
||||
val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
|
||||
{
|
||||
Checkbox(
|
||||
checked = selectedUsers.any { it.userId == roomMember.userId },
|
||||
onCheckedChange = { onSelectionToggled(roomMember) },
|
||||
if (searchResults.admins.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
|
||||
// Add a footer for the admin section in change role to moderator screen
|
||||
if (currentRole == RoomMember.Role.MODERATOR) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
MatrixUserRow(
|
||||
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
|
||||
matrixUser = MatrixUser(
|
||||
userId = roomMember.userId,
|
||||
displayName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl,
|
||||
),
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
items(searchResults.admins, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onSelectionToggled = onSelectionToggled,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchResults.moderators.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_moderators)) }
|
||||
items(searchResults.moderators, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onSelectionToggled = onSelectionToggled,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchResults.members.isNotEmpty()) {
|
||||
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_member_list_mode_members)) }
|
||||
items(searchResults.members, key = { it.userId }) { roomMember ->
|
||||
ListMemberItem(
|
||||
roomMember = roomMember,
|
||||
canRemoveMember = canRemoveMember,
|
||||
onSelectionToggled = onSelectionToggled,
|
||||
selectedUsers = selectedUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListSectionHeader(text: String) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListMemberItem(
|
||||
roomMember: RoomMember,
|
||||
canRemoveMember: (UserId) -> Boolean,
|
||||
onSelectionToggled: (RoomMember) -> Unit,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
) {
|
||||
val canToggle = canRemoveMember(roomMember.userId)
|
||||
val trailingContent: @Composable (() -> Unit) = {
|
||||
Checkbox(
|
||||
checked = selectedUsers.any { it.userId == roomMember.userId },
|
||||
onCheckedChange = { onSelectionToggled(roomMember) },
|
||||
enabled = canToggle,
|
||||
)
|
||||
}
|
||||
MemberRow(
|
||||
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
|
||||
avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem),
|
||||
name = roomMember.getBestName(),
|
||||
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
|
||||
isPending = roomMember.membership == RoomMembershipState.INVITE,
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberRow(
|
||||
avatarData: AvatarData,
|
||||
name: String,
|
||||
userId: String?,
|
||||
isPending: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Avatar(avatarData)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
// Invitation pending marker
|
||||
if (isPending) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResource(id = R.string.screen_room_member_list_pending_header_title),
|
||||
style = ElementTheme.typography.fontBodySmRegular.copy(fontStyle = FontStyle.Italic),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
// Id
|
||||
userId?.let {
|
||||
Text(
|
||||
text = userId,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +398,27 @@ internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::
|
||||
ElementPreview {
|
||||
ChangeRolesView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
navigateUp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PendingMemberRowWithLongNamePreview() {
|
||||
ElementPreview {
|
||||
MemberRow(
|
||||
avatarData = AvatarData("userId", "A very long name that should be truncated", "https://example.com/avatar.png", AvatarSize.UserListItem),
|
||||
name = "A very long name that should be truncated",
|
||||
userId = "@alice:matrix.org",
|
||||
isPending = true,
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -80,29 +81,35 @@ fun ChangeRoomPermissionsView(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
for ((index, permissionItem) in state.items.withIndex()) {
|
||||
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.ADMIN,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
}
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.MODERATOR,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
}
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.USER,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
item {
|
||||
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.ADMIN,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
}
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.MODERATOR,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
}
|
||||
SelectRoleItem(
|
||||
permissionsItem = permissionItem,
|
||||
role = RoomMember.Role.USER,
|
||||
currentPermissions = state.currentPermissions
|
||||
) { item, role ->
|
||||
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
|
||||
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
|
||||
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
|
||||
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
|
||||
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
|
||||
<string name="screen_room_change_role_section_administrators">"Admins"</string>
|
||||
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
|
||||
@@ -58,6 +59,7 @@
|
||||
<string name="screen_room_details_room_name_label">"Room name"</string>
|
||||
<string name="screen_room_details_security_title">"Security"</string>
|
||||
<string name="screen_room_details_share_room_title">"Share room"</string>
|
||||
<string name="screen_room_details_title">"Room info"</string>
|
||||
<string name="screen_room_details_topic_title">"Topic"</string>
|
||||
<string name="screen_room_details_updating_room">"Updating room…"</string>
|
||||
<string name="screen_room_member_list_ban_member_confirmation_action">"Ban"</string>
|
||||
|
||||
@@ -32,9 +32,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.aRoomMemb
|
||||
import io.element.android.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
@@ -241,14 +238,12 @@ private fun TestScope.createPresenter(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
|
||||
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
|
||||
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
|
||||
) = RoomMemberListPresenter(
|
||||
room = matrixRoom,
|
||||
roomMemberListDataSource = roomMemberListDataSource,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
featureFlagService = featureFlagService,
|
||||
roomMembersModerationPresenter = moderationPresenter,
|
||||
navigator = navigator
|
||||
)
|
||||
|
||||
@@ -28,8 +28,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.Moderatio
|
||||
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
@@ -45,13 +43,6 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultRoomMembersModerationPresenterTests {
|
||||
@Test
|
||||
fun `canDisplayModerationActions - when feature flag is disabled returns false`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to false))
|
||||
val presenter = createDefaultRoomMembersModerationPresenter(featureFlagService = featureFlagService)
|
||||
assertThat(presenter.canDisplayModerationActions()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
|
||||
val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
|
||||
@@ -309,13 +300,11 @@ class DefaultRoomMembersModerationPresenterTests {
|
||||
|
||||
private fun TestScope.createDefaultRoomMembersModerationPresenter(
|
||||
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
): DefaultRoomMembersModerationPresenter {
|
||||
return DefaultRoomMembersModerationPresenter(
|
||||
room = matrixRoom,
|
||||
featureFlagService = featureFlagService,
|
||||
dispatchers = dispatchers,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.RoomModeration
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesPresenter
|
||||
@@ -30,6 +29,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
@@ -106,15 +106,19 @@ class ChangeRolesPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(initialResults).hasSize(10)
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(initialResults?.members).hasSize(8)
|
||||
assertThat(initialResults?.moderators).hasSize(1)
|
||||
assertThat(initialResults?.admins).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
|
||||
skipItems(1)
|
||||
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(searchResults).hasSize(1)
|
||||
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(searchResults?.admins).hasSize(1)
|
||||
assertThat(searchResults?.moderators).isEmpty()
|
||||
assertThat(searchResults?.members).isEmpty()
|
||||
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +132,19 @@ class ChangeRolesPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(initialResults).hasSize(10)
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(initialResults?.members).hasSize(8)
|
||||
assertThat(initialResults?.moderators).hasSize(1)
|
||||
assertThat(initialResults?.admins).hasSize(1)
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
|
||||
skipItems(1)
|
||||
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(searchResults).hasSize(1)
|
||||
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||
assertThat(searchResults?.admins).hasSize(1)
|
||||
assertThat(searchResults?.moderators).isEmpty()
|
||||
assertThat(searchResults?.members).isEmpty()
|
||||
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,10 +162,10 @@ class ChangeRolesPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(2)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(1)
|
||||
}
|
||||
}
|
||||
@@ -177,13 +185,13 @@ class ChangeRolesPresenterTests {
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(2)
|
||||
assertThat(hasPendingChanges).isTrue()
|
||||
}
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(1)
|
||||
assertThat(hasPendingChanges).isFalse()
|
||||
@@ -226,7 +234,7 @@ class ChangeRolesPresenterTests {
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Exit)
|
||||
val confirmingState = awaitItem()
|
||||
@@ -252,7 +260,7 @@ class ChangeRolesPresenterTests {
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
val updatedState = awaitItem()
|
||||
assertThat(updatedState.hasPendingChanges).isTrue()
|
||||
skipItems(1)
|
||||
@@ -279,8 +287,7 @@ class ChangeRolesPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming)
|
||||
@@ -304,7 +311,7 @@ class ChangeRolesPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
@@ -334,7 +341,7 @@ class ChangeRolesPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
@@ -357,7 +364,7 @@ class ChangeRolesPresenterTests {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val failedState = awaitItem()
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomdetails.rolesandpermissions.changeroles
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesState
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesView
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.aChangeRolesState
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.aChangeRolesStateWithSelectedUsers
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangeRolesViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `passing a 'USER' role throws an exception`() {
|
||||
val exception = runCatching {
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.USER,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertThat(exception).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key - with search active toggles the search`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBackKey()
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key - with search inactive exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBackKey()
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button - exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.pressBack()
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save button - with changes, it saves them`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
hasPendingChanges = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save button - with no changes, does nothing`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
hasPendingChanges = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
|
||||
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit confirmation dialog - submit exits the screen`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
exitState = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit confirmation dialog - cancel removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
exitState = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save confirmation dialog - submit saves the changes`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.ADMIN,
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save confirmation dialog - cancel removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
role = RoomMember.Role.ADMIN,
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.Confirming,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog - dismissing removes the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesState(
|
||||
isSearchActive = true,
|
||||
savingState = AsyncAction.Failure(IllegalStateException("boom")),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
|
||||
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testing removing user from selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val userToDeselect = selectedUsers[1]
|
||||
assertThat(userToDeselect.displayName).isEqualTo("Bob")
|
||||
rule.setChangeRolesContent(
|
||||
state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Unselect the user from the row list
|
||||
val contentDescription = rule.activity.getString(CommonStrings.action_remove)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToDeselect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1000dp")
|
||||
fun `testing adding user to the selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.members.first().toMatrixUser()
|
||||
assertThat(userToSelect.displayName).isEqualTo("Carol")
|
||||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Select the user from the row list
|
||||
rule.onNodeWithText("Carol").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToSelect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testing removing user to the selected list emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||
val selectedUsers = aMatrixUserList().take(2)
|
||||
val state = aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.moderators.first().toMatrixUser()
|
||||
assertThat(userToSelect.displayName).isEqualTo("Bob")
|
||||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Select the user from the rom list
|
||||
rule.onAllNodesWithText("Bob")[1].performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
ChangeRolesEvent.UserSelectionToggled(userToSelect),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
|
||||
state: ChangeRolesState,
|
||||
onBackPressed: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ChangeRolesView(
|
||||
state = state,
|
||||
navigateUp = onBackPressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomdetails.rolesandpermissions.changeroles
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.MembersByRole
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_4
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_5
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
|
||||
class MembersByRoleTest {
|
||||
@Test
|
||||
fun `constructor - with single member list categorizes and sorts members`() {
|
||||
val members = listOf(
|
||||
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
|
||||
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
|
||||
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
|
||||
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
|
||||
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
|
||||
)
|
||||
val membersByRole = MembersByRole(members = members)
|
||||
assertThat(membersByRole.admins).containsExactly(
|
||||
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
|
||||
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
|
||||
)
|
||||
assertThat(membersByRole.moderators).isEmpty()
|
||||
assertThat(membersByRole.members).containsExactly(
|
||||
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
|
||||
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
|
||||
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEmpty - only returns true with no members of any role`() {
|
||||
val emptyMembersByRole = MembersByRole(emptyList())
|
||||
assertThat(emptyMembersByRole.isEmpty()).isTrue()
|
||||
|
||||
val membersByRoleWithAdmins = MembersByRole(
|
||||
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.ADMIN)),
|
||||
moderators = persistentListOf(),
|
||||
members = persistentListOf(),
|
||||
)
|
||||
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
|
||||
|
||||
val membersByRoleWithModerators = MembersByRole(
|
||||
admins = persistentListOf(),
|
||||
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.MODERATOR)),
|
||||
members = persistentListOf(),
|
||||
)
|
||||
assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
|
||||
|
||||
val membersByRoleWithMembers = MembersByRole(
|
||||
admins = persistentListOf(),
|
||||
moderators = persistentListOf(),
|
||||
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.USER)),
|
||||
)
|
||||
assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"You can add a chat to your favourites in the chat settings.
|
||||
For now, you can deselect filters in order to see your other chats"</string>
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_title">"You don’t have favourite chats yet"</string>
|
||||
<string name="screen_roomlist_filter_invites">"Invites"</string>
|
||||
<string name="screen_roomlist_filter_invites_empty_state_title">"You don\'t have any pending invites."</string>
|
||||
<string name="screen_roomlist_filter_low_priority">"Low Priority"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"You can deselect filters in order to see your other chats"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"You don’t have chats for this selection"</string>
|
||||
|
||||
@@ -31,6 +31,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : InitialTarget
|
||||
}
|
||||
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
@@ -38,6 +41,7 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onCreateNewRecoveryKey()
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.features.securebackup.impl.createkey.CreateNewRecoveryKeyNode
|
||||
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
|
||||
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
|
||||
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
|
||||
@@ -50,6 +51,7 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
||||
initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) {
|
||||
SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root
|
||||
SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey
|
||||
SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey -> NavTarget.CreateNewRecoveryKey
|
||||
},
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
@@ -74,6 +76,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : NavTarget
|
||||
}
|
||||
|
||||
private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull()
|
||||
@@ -134,6 +139,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
||||
}
|
||||
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.CreateNewRecoveryKey -> {
|
||||
createNode<CreateNewRecoveryKeyNode>(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.createkey
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class CreateNewRecoveryKeyNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CreateNewRecoveryKeyView(
|
||||
modifier = modifier,
|
||||
onBackClicked = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.createkey
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.modifiers.squareSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateNewRecoveryKeyView(
|
||||
onBackClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClicked) })
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 40.dp),
|
||||
title = stringResource(R.string.screen_create_new_recovery_key_title),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer())
|
||||
)
|
||||
Content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content() {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Item(index = 1, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1)))
|
||||
Item(index = 2, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
|
||||
Item(
|
||||
index = 3,
|
||||
text = buildAnnotatedString {
|
||||
val resetAllAction = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
|
||||
val text = stringResource(R.string.screen_create_new_recovery_key_list_item_3, resetAllAction)
|
||||
append(text)
|
||||
val start = text.indexOf(resetAllAction)
|
||||
val end = start + resetAllAction.length
|
||||
if (start in text.indices && end in text.indices) {
|
||||
addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
|
||||
}
|
||||
}
|
||||
)
|
||||
Item(index = 4, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4)))
|
||||
Item(index = 5, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Item(index: Int, text: AnnotatedString) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ItemNumber(index = index)
|
||||
Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemNumber(
|
||||
index: Int,
|
||||
) {
|
||||
val color = ElementTheme.colors.textPlaceholder
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.border(1.dp, color, CircleShape)
|
||||
.squareSize()
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(1.5.dp),
|
||||
text = index.toString(),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = color,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CreateNewRecoveryKeyViewPreview() {
|
||||
ElementPreview {
|
||||
CreateNewRecoveryKeyView(
|
||||
onBackClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ private fun ColumnScope.Buttons(
|
||||
state: SecureBackupEnterRecoveryKeyState,
|
||||
) {
|
||||
Button(
|
||||
text = stringResource(id = CommonStrings.action_confirm),
|
||||
text = stringResource(id = CommonStrings.action_continue),
|
||||
enabled = state.isSubmitEnabled,
|
||||
showProgress = state.submitAction.isLoading(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -70,7 +70,10 @@ internal fun RecoveryKeyView(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.common_recovery_key),
|
||||
text = when (state.recoveryKeyUserStory) {
|
||||
RecoveryKeyUserStory.Enter -> stringResource(R.string.screen_recovery_key_confirm_key_label)
|
||||
else -> stringResource(id = CommonStrings.common_recovery_key)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Ваша рэзервовая копія чата зараз не сінхранізавана."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Наладзьце аднаўленне"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Атрымайце доступ да зашыфраваных паведамленняў, калі вы страціце ўсе свае прылады або выйдзеце з сістэмы %1$s усюды."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Адкрыйце Element на настольнай прыладзе"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Увайдзіце ў свой уліковы запіс яшчэ раз"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Калі будзе прапанавана пацвердзіць вашу прыладу, выберыце %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Скінуць усе”"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Адключыць"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"</string>
|
||||
@@ -21,13 +28,15 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Пераканайцеся, што вы можаце захаваць ключ аднаўлення ў бяспечным месцы"</string>
|
||||
<string name="screen_recovery_key_change_success">"Ключ аднаўлення зменены"</string>
|
||||
<string name="screen_recovery_key_change_title">"Змяніць ключ аднаўлення?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Увядзіце ключ аднаўлення, каб пацвердзіць доступ да рэзервовай копіі чата."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Стварыць новы ключ аднаўлення"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Пераканайцеся, што ніхто не бачыць гэты экран!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Паўтарыце спробу, каб пацвердзіць доступ да рэзервовай копіі чата."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Няправільны ключ аднаўлення"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Увядзіце код з 48 сімвалаў."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Калі ў вас ёсць ключ аднаўлення або парольная фраза, гэта таксама будзе працаваць."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Ключ аднаўлення або код доступу"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Увесці…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Ключ аднаўлення пацверджаны"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Пацвердзіце ключ аднаўлення"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Увядзіце ключ аднаўлення або код доступу"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Ключ аднаўлення скапіраваны"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Стварэнне…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Захаваць ключ аднаўлення"</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaše záloha chatu není aktuálně synchronizována."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Nastavení obnovy"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Získejte přístup ke svým zašifrovaným zprávám, pokud ztratíte všechna zařízení nebo jste všude odhlášeni z %1$s."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Otevřít Element na stolním počítači"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Znovu se přihlaste ke svému účtu"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Když budete vyzváni k ověření vašeho zařízení, vyberte %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"\"Resetovat vše\""</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Postupujte podle pokynů k vytvoření nového obnovovacího klíče"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Obnovte šifrování účtu pomocí jiného zařízení"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnout"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Pokud se odhlásíte ze všech zařízení, přijdete o zašifrované zprávy."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Opravdu chcete vypnout zálohování?"</string>
|
||||
@@ -21,10 +28,12 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Ujistěte se, že můžete klíč pro obnovení uložit někde v bezpečí"</string>
|
||||
<string name="screen_recovery_key_change_success">"Klíč pro obnovení byl změněn"</string>
|
||||
<string name="screen_recovery_key_change_title">"Změnit klíč pro obnovení?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Zadejte klíč pro obnovení a potvrďte přístup k záloze chatu."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Vytvořit nový klíč pro obnovení"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Ujistěte se, že tuto obrazovku nikdo nevidí!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Zkuste prosím znovu potvrdit přístup k záloze chatu."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Nesprávný klíč pro obnovení"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Zadejte kód o délce 48 znaků."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Pokud máte bezpečnostní klíč nebo bezpečnostní frázi, bude to fungovat také."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Klíč pro obnovení nebo přístupový kód"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Zadejte…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Klíč pro obnovení potvrzen"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Potvrďte klíč pro obnovení"</string>
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Chat-Backup ist derzeit nicht synchronisiert."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Wiederherstellung einrichten"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verlierst oder von %1$s überall abgemeldet bist."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">
|
||||
"Öffne "
|
||||
<b>"Element"</b>
|
||||
" auf einem "
|
||||
<b>"Desktop-Gerät"</b>
|
||||
</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Melde dich erneut bei deinem Konto an"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Wenn du aufgefordert wirst dein Gerät zu verifizieren, wähle \"%1$s\"."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"Alles zurücksetzen"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Folge den Anweisungen, um einen neuen Wiederherstellungsschlüssel zu erstellen"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">
|
||||
"Speichere deinen neuen "
|
||||
<b>"Wiederherstellungsschlüssel"</b>
|
||||
" in einem Passwortmanager oder einer verschlüsselten Notiz"
|
||||
</string>
|
||||
<string name="screen_create_new_recovery_key_title">
|
||||
"Erstelle einen neuen "
|
||||
<b>"Wiederherstellungsschlüssel"</b>
|
||||
" mit einem anderen Gerät"
|
||||
</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Ausschalten"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Bist du sicher, dass du das Backup ausschalten willst?"</string>
|
||||
@@ -21,13 +41,22 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"</string>
|
||||
<string name="screen_recovery_key_change_success">"Wiederherstellungsschlüssel geändert"</string>
|
||||
<string name="screen_recovery_key_change_title">"Wiederherstellungsschlüssel ändern?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Gib deinen Wiederherstellungsschlüssel ein, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">
|
||||
"Neuen "
|
||||
<b>"Wiederherstellungsschlüssel"</b>
|
||||
" erstellen"
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Sorge dafür, dass niemand diesen Bildschirm sehen kann!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Falscher Wiederherstellungsschlüssel"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Gib den 48-stelligen Code ein."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">
|
||||
<b>"Wiederherstellungsschlüssel"</b>
|
||||
" oder Passcode"
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Eingeben…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel bestätigen."</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel oder Passcode bestätigen"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Wiederherstellungsschlüssel kopiert"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Generieren…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Wiederherstellungsschlüssel speichern"</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"A csevegéselőzményei nincsenek szinkronban."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Helyreállítás beállítása"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Szerezzen hozzáférést a titkosított üzeneteihez, ha elvesztette az összes eszközét, vagy ha mindenütt kijelentkezett az %1$sből."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Nyissa meg az Elementet egy asztali eszközön"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Jelentkezzen be újra a fiókjába"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Amikor az eszköz ellenőrzését kéri, válassza ezt a lehetőséget: %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"„Minden visszaállítása”"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Kövesse az utasításokat egy új helyreállítási kulcs létrehozásához"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Mentse az új helyreállítási kulcsot egy jelszókezelőbe vagy egy titkosított jegyzetbe."</string>
|
||||
<string name="screen_create_new_recovery_key_title">"A fiók titkosításának visszaállítása egy másik eszköz használatával"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Kikapcsolás"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Biztos, hogy kikapcsolja a biztonsági mentéseket?"</string>
|
||||
@@ -21,13 +28,15 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"</string>
|
||||
<string name="screen_recovery_key_change_success">"Helyreállítási kulcs lecserélve"</string>
|
||||
<string name="screen_recovery_key_change_title">"Módosítja a helyreállítási kulcsot?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Adja meg a helyreállítási kulcsát, hogy megerősítse a csevegések biztonsági mentéséhez való hozzáférését."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Új helyreállítási kulcs létrehozása"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Győződjön meg arról, hogy senki sem látja ezt a képernyőt!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Próbálja meg újra megerősíteni a csevegés biztonsági mentéséhez való hozzáférését."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Helytelen helyreállítási kulcs"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Adja meg a 48 karakteres kódot."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Ha van helyreállítási kulcsa vagy titkos jelmondata/kulcsa, akkor ez is fog működni."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Helyreállítási kulcs vagy jelkód"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Megadás…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Helyreállítási kulcs megerősítve"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Erősítse meg a helyreállítási kulcsát"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Adja meg a helyreállítási kulcsát vagy a jelkódját"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Helyreállítási kulcs másolva"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Előállítás…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Helyreállítási kulcs mentése"</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Pencadangan percakapan Anda saat ini tidak tersinkron."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Siapkan pemulihan"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Dapatkan akses ke pesan terenkripsi Anda jika Anda kehilangan semua perangkat Anda atau keluar dari %1$s di mana pun."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Buka Element di perangkat desktop"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Masuk ke akun Anda lagi"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Saat diminta untuk memverifikasi perangkat Anda, pilih %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Atur ulang semua”"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Ikuti petunjuk untuk membuat kunci pemulihan baru"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Simpan kunci pemulihan baru Anda dalam pengelola kata sandi atau catatan terenkripsi"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Atur ulang enkripsi untuk akun Anda menggunakan perangkat lain"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Matikan"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Anda akan kehilangan pesan terenkripsi jika Anda keluar dari semua perangkat."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Apakah Anda yakin ingin mematikan pencadangan?"</string>
|
||||
@@ -21,13 +28,15 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Pastikan Anda dapat menyimpan kunci pemulihan Anda di tempat yang aman"</string>
|
||||
<string name="screen_recovery_key_change_success">"Kunci pemulihan diganti"</string>
|
||||
<string name="screen_recovery_key_change_title">"Ubah kunci pemulihan?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Masukkan kunci pemulihan Anda untuk mengonfirmasi akses ke cadangan percakapan Anda."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Pastikan tidak ada yang bisa melihat layar ini!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Silakan coba lagi untuk mengonfirmasi akses ke cadangan percakapan Anda."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Kunci pemulihan salah"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Masukkan kode 48 karakter."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Jika Anda memiliki frasa sandi pemulihan atau frasa/kunci sandi rahasia, ini juga dapat digunakan."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Kunci pemulihan atau kode sandi"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Masukkan…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Kunci pemulihan dikonfirmasi"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Konfirmasi kunci pemulihan Anda"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Konfirmasi kunci pemulihan atau kode sandi Anda"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Kunci pemulihan disalin"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Membuat…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Simpan kunci pemulihan"</string>
|
||||
|
||||
@@ -4,11 +4,28 @@
|
||||
<string name="screen_chat_backup_key_backup_action_enable">"Включить резервное копирование"</string>
|
||||
<string name="screen_chat_backup_key_backup_description">"Резервное копирование гарантирует, что вы не потеряете историю сообщений. %1$s."</string>
|
||||
<string name="screen_chat_backup_key_backup_title">"Резервное копирование"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Изменить ключ восстановления"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Подтвердить ключ восстановления"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">
|
||||
"Изменить "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">
|
||||
"Ввести "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Резервная копия чата в настоящее время не синхронизирована."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Настроить восстановление"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Получите доступ к зашифрованным сообщениям, если вы потеряете все свои устройства или выйдете из системы %1$s отовсюду."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Откройте Element на настольном устройстве"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Войдите в свой аккаунт еще раз"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Когда вас попросят подтвердить устройство, выберите %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Сбросить все”"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Следуйте инструкциям, чтобы создать новый ключ восстановления"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">
|
||||
"Сохраните новый "
|
||||
<b>"ключ восстановления"</b>
|
||||
" в менеджере паролей или зашифрованной заметке"
|
||||
</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Сбросьте шифрование вашей учетной записи с помощью другого устройства."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Выключить"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</string>
|
||||
@@ -17,27 +34,62 @@
|
||||
<string name="screen_key_backup_disable_description_point_2">"Потерять доступ к зашифрованным сообщениям, если вы вышли из %1$s любой точки мира"</string>
|
||||
<string name="screen_key_backup_disable_title">"Вы действительно хотите отключить резервное копирование?"</string>
|
||||
<string name="screen_recovery_key_change_description">"Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать."</string>
|
||||
<string name="screen_recovery_key_change_generate_key">"Создать новый ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_change_generate_key">
|
||||
"Создать новый "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"</string>
|
||||
<string name="screen_recovery_key_change_success">"Ключ восстановления изменен"</string>
|
||||
<string name="screen_recovery_key_change_success">
|
||||
<b>"Ключ восстановления"</b>
|
||||
" изменен"
|
||||
</string>
|
||||
<string name="screen_recovery_key_change_title">"Изменить ключ восстановления?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Введите ключ восстановления, чтобы подтвердить доступ к резервной копии чата."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">
|
||||
"Создать новый "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Убедитесь, что никто не видит этот экран!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Пожалуйста, попробуйте еще раз, чтобы подтвердить доступ к резервной копии чата."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Неверный ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Введите 48 значный код."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">
|
||||
"Неверный "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Если у вас есть пароль для восстановления или секретный пароль/ключ, это тоже сработает."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">
|
||||
<b>"Ключ восстановления"</b>
|
||||
" или пароль"
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Вход…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Ключ восстановления подтвержден"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Подтвердите ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Ключ восстановления скопирован"</string>
|
||||
<string name="screen_recovery_key_confirm_success">
|
||||
<b>"Ключ восстановления"</b>
|
||||
" подтвержден"
|
||||
</string>
|
||||
<string name="screen_recovery_key_confirm_title">
|
||||
"Подтвердите "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">
|
||||
<b>"Ключ восстановления"</b>
|
||||
" скопирован"
|
||||
</string>
|
||||
<string name="screen_recovery_key_generating_key">"Генерация…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Сохранить ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_save_action">
|
||||
"Сохранить "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_save_description">"Запишите ключ восстановления в безопасном месте или сохраните его в менеджере паролей."</string>
|
||||
<string name="screen_recovery_key_save_key_description">"Нажмите, чтобы скопировать ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_save_title">"Сохраните ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_save_title">
|
||||
"Сохраните "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_description">"После этого шага вы не сможете получить доступ к новому ключу восстановления."</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_title">"Вы сохранили ключ восстановления?"</string>
|
||||
<string name="screen_recovery_key_setup_description">"Резервная копия чата защищена ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав «Изменить ключ восстановления»."</string>
|
||||
<string name="screen_recovery_key_setup_generate_key">"Сгенерируйте свой ключ восстановления"</string>
|
||||
<string name="screen_recovery_key_setup_generate_key">
|
||||
"Создайте "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_recovery_key_setup_generate_key_description">"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"</string>
|
||||
<string name="screen_recovery_key_setup_success">"Настройка восстановления выполнена успешно"</string>
|
||||
<string name="screen_recovery_key_setup_title">"Настроить восстановление"</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaša záloha konverzácie nie je momentálne synchronizovaná."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Nastaviť obnovovanie"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Otvoriť Element v stolnom počítači"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Znova sa prihláste do svojho účtu"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"Keď sa zobrazí výzva na overenie vášho zariadenia, vyberte %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"\"Obnoviť všetko\""</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Postupujte podľa pokynov na vytvorenie nového kľúča na obnovenie"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Uložte si nový kľúč na obnovenie do správcu hesiel alebo do zašifrovanej poznámky"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Obnovte šifrovanie vášho účtu pomocou iného zariadenia"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnúť"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
|
||||
@@ -21,13 +28,15 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
|
||||
<string name="screen_recovery_key_change_success">"Kľúč na obnovenie bol zmenený"</string>
|
||||
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Vytvoriť nový kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Uistite sa, že túto obrazovku nikto nevidí!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Skúste prosím znova potvrdiť prístup k vašej zálohe konverzácie."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Nesprávny kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Zadajte 48-znakový kód."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Ak máte frázu na obnovenie alebo tajné heslo/kľúč, bude to tiež fungovať."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Kľúč na obnovenie alebo prístupový kód"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Zadajte kľúč na obnovenie alebo prístupový kód"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Generovanie…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_1">"Open Element in a desktop device"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_2">"Sign into your account again"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3">"When asked to verify your device, select %1$s"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Reset all”"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_4">"Follow the instructions to create a new recovery key"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Save your new recovery key in a password manager or encrypted note"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Reset the encryption for your account using another device"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
|
||||
@@ -21,13 +28,15 @@
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
|
||||
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
|
||||
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
|
||||
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Create new recovery key"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Make sure nobody can see this screen!"</string>
|
||||
<string name="screen_recovery_key_confirm_error_content">"Please try again to confirm access to your chat backup."</string>
|
||||
<string name="screen_recovery_key_confirm_error_title">"Incorrect recovery key"</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"If you have a security key or security phrase, this will work too."</string>
|
||||
<string name="screen_recovery_key_confirm_key_label">"Recovery key or passcode"</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Enter your recovery key"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Enter your recovery key or passcode"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Generating…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Save recovery key"</string>
|
||||
|
||||
@@ -38,6 +38,7 @@ fun aSignedOutState() = SignedOutState(
|
||||
fun aSessionData(
|
||||
sessionId: SessionId = SessionId("@alice:server.org"),
|
||||
isTokenValid: Boolean = false,
|
||||
needsVerification: Boolean = false,
|
||||
): SessionData {
|
||||
return SessionData(
|
||||
userId = sessionId.value,
|
||||
@@ -51,5 +52,6 @@ fun aSessionData(
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = LoginType.UNKNOWN,
|
||||
passphrase = null,
|
||||
needsVerification = needsVerification,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onEnterRecoveryKey()
|
||||
fun onCreateNewRecoveryKey()
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,17 +34,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: VerifySelfSessionPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private fun onEnterRecoveryKey() {
|
||||
plugins<VerifySessionEntryPoint.Callback>().forEach {
|
||||
it.onEnterRecoveryKey()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDone() {
|
||||
plugins<VerifySessionEntryPoint.Callback>().forEach {
|
||||
it.onDone()
|
||||
}
|
||||
}
|
||||
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
@@ -52,8 +42,9 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onEnterRecoveryKey = ::onEnterRecoveryKey,
|
||||
onFinished = ::onDone,
|
||||
onEnterRecoveryKey = callback::onEnterRecoveryKey,
|
||||
onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey,
|
||||
onFinished = callback::onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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 com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
@@ -35,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
|
||||
@@ -43,20 +48,28 @@ class VerifySelfSessionPresenter @Inject constructor(
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val stateMachine: VerifySelfSessionStateMachine,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<VerifySelfSessionState> {
|
||||
@Composable
|
||||
override fun present(): VerifySelfSessionState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
// Force reset, just in case the service was left in a broken state
|
||||
sessionVerificationService.reset()
|
||||
}
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
var skipVerification by remember { mutableStateOf(false) }
|
||||
val needsVerification by sessionVerificationService.needsVerificationFlow.collectAsState()
|
||||
val verificationFlowStep by remember {
|
||||
derivedStateOf {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
||||
)
|
||||
when {
|
||||
skipVerification -> VerifySelfSessionState.VerificationStep.Skipped
|
||||
needsVerification -> stateAndDispatch.state.value.toVerificationStep(
|
||||
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
|
||||
)
|
||||
else -> VerifySelfSessionState.VerificationStep.Completed
|
||||
}
|
||||
}
|
||||
}
|
||||
// Start this after observing state machine
|
||||
@@ -72,10 +85,15 @@ class VerifySelfSessionPresenter @Inject constructor(
|
||||
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
|
||||
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
|
||||
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
|
||||
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
|
||||
sessionVerificationService.saveVerifiedState(true)
|
||||
skipVerification = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
displaySkipButton = buildMeta.isDebuggable,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -85,7 +103,7 @@ class VerifySelfSessionPresenter @Inject constructor(
|
||||
): VerifySelfSessionState.VerificationStep =
|
||||
when (val machineState = this) {
|
||||
StateMachineState.Initial, null -> {
|
||||
VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey)
|
||||
VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey, isLastDevice = encryptionService.isLastDevice.value)
|
||||
}
|
||||
StateMachineState.RequestingVerification,
|
||||
StateMachineState.StartingSasVerification,
|
||||
|
||||
@@ -24,15 +24,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
|
||||
@Immutable
|
||||
data class VerifySelfSessionState(
|
||||
val verificationFlowStep: VerificationStep,
|
||||
val displaySkipButton: Boolean,
|
||||
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
|
||||
) {
|
||||
@Stable
|
||||
sealed interface VerificationStep {
|
||||
data class Initial(val canEnterRecoveryKey: Boolean) : VerificationStep
|
||||
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean) : VerificationStep
|
||||
data object Canceled : VerificationStep
|
||||
data object AwaitingOtherDeviceResponse : VerificationStep
|
||||
data object Ready : VerificationStep
|
||||
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
|
||||
data object Completed : VerificationStep
|
||||
data object Skipped : VerificationStep
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.verifysession.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
@@ -24,27 +25,34 @@ import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
|
||||
override val values: Sequence<VerifySelfSessionState>
|
||||
get() = sequenceOf(
|
||||
aVerifySelfSessionState(),
|
||||
aVerifySelfSessionState(displaySkipButton = true),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
|
||||
verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
|
||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||
verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled
|
||||
verificationFlowStep = VerificationStep.Canceled
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready
|
||||
verificationFlowStep = VerificationStep.Ready
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||
verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true)
|
||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = false)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
|
||||
),
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerificationStep.Completed,
|
||||
displaySkipButton = true,
|
||||
),
|
||||
// Add other state here
|
||||
)
|
||||
@@ -63,10 +71,12 @@ private fun aDecimalsSessionVerificationData(
|
||||
}
|
||||
|
||||
internal fun aVerifySelfSessionState(
|
||||
verificationFlowStep: VerifySelfSessionState.VerificationStep = VerifySelfSessionState.VerificationStep.Initial(false),
|
||||
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false, isLastDevice = false),
|
||||
displaySkipButton: Boolean = false,
|
||||
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
|
||||
) = VerifySelfSessionState(
|
||||
verificationFlowStep = verificationFlowStep,
|
||||
displaySkipButton = displaySkipButton,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,8 +28,12 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -51,21 +55,30 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerifySelfSessionView(
|
||||
state: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onCreateNewRecoveryKey: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun resetFlow() {
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
}
|
||||
val updatedOnFinished by rememberUpdatedState(newValue = onFinished)
|
||||
LaunchedEffect(state.verificationFlowStep, updatedOnFinished) {
|
||||
if (state.verificationFlowStep is FlowStep.Skipped) {
|
||||
updatedOnFinished()
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
when (state.verificationFlowStep) {
|
||||
is FlowStep.Canceled -> resetFlow()
|
||||
@@ -81,6 +94,19 @@ fun VerifySelfSessionView(
|
||||
val verificationFlowStep = state.verificationFlowStep
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
if (state.displaySkipButton && state.verificationFlowStep != FlowStep.Completed) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_skip),
|
||||
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
header = {
|
||||
HeaderContent(verificationFlowStep = verificationFlowStep)
|
||||
},
|
||||
@@ -89,6 +115,7 @@ fun VerifySelfSessionView(
|
||||
screenState = state,
|
||||
goBack = ::resetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onCreateNewRecoveryKey = onCreateNewRecoveryKey,
|
||||
onFinished = onFinished,
|
||||
)
|
||||
}
|
||||
@@ -104,6 +131,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
FlowStep.Canceled -> BigIcon.Style.AlertSolid
|
||||
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
|
||||
FlowStep.Completed -> BigIcon.Style.SuccessSolid
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
val titleTextId = when (verificationFlowStep) {
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
|
||||
@@ -114,20 +142,21 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
val subtitleTextId = when (verificationFlowStep) {
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
|
||||
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
|
||||
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmation_subtitle
|
||||
FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
|
||||
is FlowStep.Verifying -> when (verificationFlowStep.data) {
|
||||
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
|
||||
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 60.dp),
|
||||
iconStyle = iconStyle,
|
||||
title = stringResource(id = titleTextId),
|
||||
subtitle = stringResource(id = subtitleTextId)
|
||||
@@ -137,9 +166,8 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
|
||||
@Composable
|
||||
private fun Content(flowState: FlowStep) {
|
||||
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
|
||||
when (flowState) {
|
||||
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
|
||||
is FlowStep.Verifying -> ContentVerifying(flowState)
|
||||
if (flowState is FlowStep.Verifying) {
|
||||
ContentVerifying(flowState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +228,7 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
|
||||
private fun BottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onCreateNewRecoveryKey: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
) {
|
||||
@@ -210,12 +239,21 @@ private fun BottomMenu(
|
||||
|
||||
when (verificationViewState) {
|
||||
is FlowStep.Initial -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
|
||||
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onNegativeButtonClicked = onEnterRecoveryKey,
|
||||
)
|
||||
if (verificationViewState.isLastDevice) {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onPositiveButtonClicked = onEnterRecoveryKey,
|
||||
negativeButtonTitle = stringResource(R.string.screen_identity_confirmation_create_new_recovery_key),
|
||||
onNegativeButtonClicked = onCreateNewRecoveryKey,
|
||||
)
|
||||
} else {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
|
||||
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onNegativeButtonClicked = onEnterRecoveryKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Canceled -> {
|
||||
BottomMenu(
|
||||
@@ -264,6 +302,7 @@ private fun BottomMenu(
|
||||
onPositiveButtonClicked = onFinished,
|
||||
)
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +346,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onEnterRecoveryKey = {},
|
||||
onCreateNewRecoveryKey = {},
|
||||
onFinished = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,4 +23,5 @@ sealed interface VerifySelfSessionViewEvents {
|
||||
data object DeclineVerification : VerifySelfSessionViewEvents
|
||||
data object Cancel : VerifySelfSessionViewEvents
|
||||
data object Reset : VerifySelfSessionViewEvents
|
||||
data object SkipVerification : VerifySelfSessionViewEvents
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Стварыць новы ключ аднаўлення"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."</string>
|
||||
<string name="screen_identity_confirmation_title">"Пацвердзіце, што гэта вы"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."</string>
|
||||
<string name="screen_identity_confirmed_title">"Прылада праверана"</string>
|
||||
<string name="screen_identity_use_another_device">"Выкарыстоўвайце іншую прыладу"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Чаканне на іншай прыладзе…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Здаецца, нешта не так. Альбо час чакання запыту скончыўся, альбо запыт быў адхілены."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Пераканайцеся, што прыведзеныя ніжэй эмодзі супадаюць з эмодзі, паказанымі ў вашым іншым сеансе."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Параўнайце эмодзі"</string>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Vytvoření nového klíče pro obnovení"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."</string>
|
||||
<string name="screen_identity_confirmation_title">"Potvrďte, že jste to vy"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."</string>
|
||||
<string name="screen_identity_confirmed_title">"Zařízení ověřeno"</string>
|
||||
<string name="screen_identity_use_another_device">"Použít jiné zařízení"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Čekání na jiném zařízení…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Něco není v pořádku. Buď vypršel časový limit požadavku, nebo byl požadavek zamítnut."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na jiné relaci."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Porovnání emotikonů"</string>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Erstelle einen neuen Wiederherstellungsschlüssel"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."</string>
|
||||
<string name="screen_identity_confirmation_title">"Bestätige, dass du es bist"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Du kannst nun verschlüsselte Nachrichten lesen oder versenden."</string>
|
||||
<string name="screen_identity_confirmed_title">"Gerät verifiziert"</string>
|
||||
<string name="screen_identity_use_another_device">"Ein anderes Gerät verwenden"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Bitte warten bis das andere Gerät bereit ist."</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Vergewissere dich dass die folgenden Emojis mit denen in deiner anderen Session übereinstimmen."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Emojis vergleichen"</string>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_subtitle">"Vérifier cette session pour configurer votre messagerie sécurisée."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirmez votre identité"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."</string>
|
||||
<string name="screen_identity_confirmed_title">"Session vérifiée"</string>
|
||||
<string name="screen_identity_use_another_device">"Utiliser une autre session"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"En attente d’une autre session…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Quelque chose ne va pas. Soit la demande a expiré, soit elle a été refusée."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Confirmez que les emojis ci-dessous correspondent à ceux affichés sur votre autre session."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Comparez les émojis"</string>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Új helyreállítási kulcs létrehozása"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."</string>
|
||||
<string name="screen_identity_confirmation_title">"Erősítse meg, hogy Ön az"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."</string>
|
||||
<string name="screen_identity_confirmed_title">"Eszköz ellenőrizve"</string>
|
||||
<string name="screen_identity_use_another_device">"Másik eszköz használata"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Várakozás a másik eszközre…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Valami hibásnak tűnik. A kérés vagy időtúllépésre futott, vagy elutasították."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Erősítse meg, hogy a lenti emodzsik egyeznek-e a másik munkamenetben megjelenítettekkel."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Emodzsik összehasonlítása"</string>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string>
|
||||
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."</string>
|
||||
<string name="screen_identity_confirmed_title">"Perangkat terverifikasi"</string>
|
||||
<string name="screen_identity_use_another_device">"Gunakan perangkat lain"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Menunggu di perangkat lain…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Sepertinya ada yang tidak beres. Entah permintaan sudah habis masa berlakunya atau permintaan ditolak."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Konfirmasikan bahwa emoji di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Bandingkan emoji"</string>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifica questo dispositivo per segnare i tuoi messaggi come sicuri."</string>
|
||||
<string name="screen_identity_confirmation_title">"Conferma la tua identità"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo."</string>
|
||||
<string name="screen_identity_confirmed_title">"Dispositivo verificato"</string>
|
||||
<string name="screen_identity_use_another_device">"Usa un altro dispositivo"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"In attesa sull\'altro dispositivo…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Confronta le emoji"</string>
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">
|
||||
"Создайте новый "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."</string>
|
||||
<string name="screen_identity_confirmation_title">"Подтвердите, что это вы"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."</string>
|
||||
<string name="screen_identity_confirmed_title">"Устройство проверено"</string>
|
||||
<string name="screen_identity_use_another_device">"Используйте другое устройство"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Ожидание на другом устройстве…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Похоже, что-то не так. Время ожидания запроса либо истекло, либо запрос был отклонен."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Убедитесь, что приведенные ниже емоджи совпадают с емоджи показанными во время другого сеанса."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Сравните емодзи"</string>
|
||||
<string name="screen_session_verification_compare_numbers_subtitle">"Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе."</string>
|
||||
<string name="screen_session_verification_compare_numbers_title">"Сравните числа"</string>
|
||||
<string name="screen_session_verification_complete_subtitle">"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное."</string>
|
||||
<string name="screen_session_verification_enter_recovery_key">"Введите ключ восстановления"</string>
|
||||
<string name="screen_session_verification_enter_recovery_key">
|
||||
"Введите "
|
||||
<b>"ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="screen_session_verification_open_existing_session_subtitle">"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы."</string>
|
||||
<string name="screen_session_verification_open_existing_session_title">"Открыть существующий сеанс"</string>
|
||||
<string name="screen_session_verification_positive_button_canceled">"Повторить проверку"</string>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Vytvoriť nový kľúč na obnovenie"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."</string>
|
||||
<string name="screen_identity_confirmation_title">"Potvrďte, že ste to vy"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."</string>
|
||||
<string name="screen_identity_confirmed_title">"Zariadenie overené"</string>
|
||||
<string name="screen_identity_use_another_device">"Použiť iné zariadenie"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Čaká sa na druhom zariadení…"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Porovnajte emotikony"</string>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_subtitle">"Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string>
|
||||
<string name="screen_identity_confirmation_title">"Підтвердіть, що це ви"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
|
||||
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"Щось не так. Або час очікування запиту минув, або в запиті було відмовлено."</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"Переконайтеся, що емодзі нижче збігаються з тими, що відображаються під час іншого сеансу."</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"Порівняти емодзі"</string>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmed_title">"裝置已認證"</string>
|
||||
<string name="screen_identity_use_another_device">"使用另一個裝置"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"正在等待其他裝置……"</string>
|
||||
<string name="screen_session_verification_cancelled_subtitle">"似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。"</string>
|
||||
<string name="screen_session_verification_compare_emojis_subtitle">"確認顯示在其他工作階段上的表情符號是否和下方的相同。"</string>
|
||||
<string name="screen_session_verification_compare_emojis_title">"比對表情符號"</string>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
|
||||
|
||||
@@ -23,15 +23,19 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -48,7 +52,21 @@ class VerifySelfSessionPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
awaitItem().run {
|
||||
assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
assertThat(displaySkipButton).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(isDebuggable = false)
|
||||
val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().displaySkipButton).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +80,28 @@ class VerifySelfSessionPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
|
||||
val presenter = createVerifySelfSessionPresenter(
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
emitIsLastDevice(true)
|
||||
emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Handles requestVerification`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -79,13 +112,13 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - Handles startSasVerification`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
|
||||
// Await for other device response:
|
||||
@@ -104,7 +137,7 @@ class VerifySelfSessionPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
val eventSink = initialState.eventSink
|
||||
eventSink(VerifySelfSessionViewEvents.Cancel)
|
||||
expectNoEvents()
|
||||
@@ -113,7 +146,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - A failure when verifying cancels it`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -130,7 +163,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -139,13 +172,13 @@ class VerifySelfSessionPresenterTests {
|
||||
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
service.shouldFail = false
|
||||
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -158,7 +191,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -171,7 +204,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -188,7 +221,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - Go back after cancelation returns to initial state`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -198,7 +231,7 @@ class VerifySelfSessionPresenterTests {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
|
||||
state.eventSink(VerifySelfSessionViewEvents.Reset)
|
||||
// Went back to initial state
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -208,7 +241,7 @@ class VerifySelfSessionPresenterTests {
|
||||
val emojis = listOf(
|
||||
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
|
||||
)
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -230,7 +263,7 @@ class VerifySelfSessionPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - When verification is declined, the flow is canceled`() = runTest {
|
||||
val service = FakeSessionVerificationService()
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -247,12 +280,39 @@ class VerifySelfSessionPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Skip event skips the flow`() = runTest {
|
||||
val service = unverifiedSessionService()
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val state = requestVerificationAndAwaitVerifyingState(service)
|
||||
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
|
||||
service.saveVerifiedStateResult.assertions().isCalledOnce().with(value(true))
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - When verification is not needed, the flow is completed`() = runTest {
|
||||
val service = FakeSessionVerificationService().apply {
|
||||
givenNeedsVerification(false)
|
||||
}
|
||||
val presenter = createVerifySelfSessionPresenter(service)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState(
|
||||
fakeService: FakeSessionVerificationService,
|
||||
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
|
||||
): VerifySelfSessionState {
|
||||
var state = awaitItem()
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
|
||||
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
|
||||
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
|
||||
// Await for other device response:
|
||||
state = awaitItem()
|
||||
@@ -271,14 +331,23 @@ class VerifySelfSessionPresenterTests {
|
||||
return state
|
||||
}
|
||||
|
||||
private fun unverifiedSessionService(): FakeSessionVerificationService {
|
||||
return FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
givenNeedsVerification(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVerifySelfSessionPresenter(
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
service: SessionVerificationService = unverifiedSessionService(),
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
): VerifySelfSessionPresenter {
|
||||
return VerifySelfSessionPresenter(
|
||||
sessionVerificationService = service,
|
||||
encryptionService = encryptionService,
|
||||
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,17 +17,20 @@
|
||||
package io.element.android.features.verifysession.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@@ -38,16 +41,12 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `back key pressed - when canceled resets the flow`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
|
||||
}
|
||||
@@ -55,16 +54,12 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `back key pressed - when awaiting response cancels the verification`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
|
||||
}
|
||||
@@ -72,16 +67,12 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `back key pressed - when ready to verify cancels the verification`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
|
||||
}
|
||||
@@ -89,19 +80,15 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `back key pressed - when verifying and not loading declines the verification`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
}
|
||||
@@ -109,19 +96,28 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `back key pressed - when verifying and loading does nothing`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Loading(),
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Loading(),
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key pressed - on Completed step does nothing`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBackKey()
|
||||
eventsRecorder.assertEmpty()
|
||||
}
|
||||
@@ -130,16 +126,13 @@ class VerifySelfSessionViewTest {
|
||||
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = callback,
|
||||
)
|
||||
}
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onFinished = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
}
|
||||
}
|
||||
@@ -149,36 +142,45 @@ class VerifySelfSessionViewTest {
|
||||
fun `clicking on enter recovery key calls the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, false),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onEnterRecoveryKey = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on create new recovery key calls the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, true),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onCreateNewRecoveryKey = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_identity_confirmation_create_new_recovery_key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on they match emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_session_verification_they_match)
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
}
|
||||
@@ -186,20 +188,60 @@ class VerifySelfSessionViewTest {
|
||||
@Test
|
||||
fun `clicking on they do not match emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
eventSink = eventsRecorder
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
|
||||
data = aEmojisSessionVerificationData(),
|
||||
state = AsyncData.Uninitialized,
|
||||
),
|
||||
onEnterRecoveryKey = EnsureNeverCalled(),
|
||||
onFinished = EnsureNeverCalled(),
|
||||
)
|
||||
}
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_session_verification_they_dont_match)
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on 'Skip' emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = false),
|
||||
displaySkipButton = true,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_skip)
|
||||
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.SkipVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on Skipped step - onFinished callback is called immediately`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setVerifySelfSessionView(
|
||||
aVerifySelfSessionState(
|
||||
verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
|
||||
displaySkipButton = true,
|
||||
eventSink = EnsureNeverCalledWithParam(),
|
||||
),
|
||||
onFinished = callback,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
|
||||
state: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
||||
onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
||||
onFinished: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
rule.setContent {
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onCreateNewRecoveryKey = onCreateNewRecoveryKey,
|
||||
onFinished = onFinished,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
[versions]
|
||||
# Project
|
||||
android_gradle_plugin = "8.3.1"
|
||||
android_gradle_plugin = "8.3.2"
|
||||
kotlin = "1.9.23"
|
||||
ksp = "1.9.23-1.0.19"
|
||||
firebaseAppDistribution = "4.2.0"
|
||||
@@ -18,7 +18,7 @@ activity = "1.8.2"
|
||||
media3 = "1.3.0"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2024.03.00"
|
||||
compose_bom = "2024.04.00"
|
||||
composecompiler = "1.5.11"
|
||||
|
||||
# Coroutines
|
||||
@@ -33,12 +33,12 @@ test_core = "1.5.0"
|
||||
#other
|
||||
coil = "2.6.0"
|
||||
datetime = "0.5.0"
|
||||
dependencyAnalysis = "1.30.0"
|
||||
dependencyAnalysis = "1.31.0"
|
||||
serialization_json = "1.6.3"
|
||||
showkase = "1.0.2"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.1"
|
||||
wysiwyg = "2.35.0"
|
||||
sqldelight = "2.0.2"
|
||||
wysiwyg = "2.36.0"
|
||||
telephoto = "0.9.0"
|
||||
|
||||
# DI
|
||||
@@ -144,7 +144,7 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
|
||||
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
|
||||
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
|
||||
compound = { module = "io.element.android:compound-android", version = "0.0.5" }
|
||||
compound = { module = "io.element.android:compound-android", version = "0.0.6" }
|
||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
||||
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
|
||||
@@ -154,7 +154,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
|
||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2"
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.12"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.13"
|
||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
@@ -176,7 +176,7 @@ kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog:posthog-android:3.1.16"
|
||||
sentry = "io.sentry:sentry-android:7.6.0"
|
||||
sentry = "io.sentry:sentry-android:7.8.0"
|
||||
# Note: only 0.19.0 will compile properly
|
||||
# main branch can be tested replacing the version with main-SNAPSHOT
|
||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.15.0"
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.R
|
||||
import io.element.android.libraries.designsystem.modifiers.blurCompat
|
||||
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
|
||||
@@ -171,6 +172,7 @@ internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
|
||||
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
|
||||
}
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
|
||||
Box(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user