Merge branch 'develop' into feature/fga/room_navigation

This commit is contained in:
ganfra 2024-04-10 16:55:55 +02:00
commit d07df2319b
447 changed files with 3318 additions and 1374 deletions

View file

@ -32,7 +32,7 @@ jobs:
mkdir -p screenshots/en mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages - name: Deploy GitHub Pages
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots publish_dir: ./screenshots

View file

@ -6,4 +6,8 @@ appId: ${MAESTRO_APP_ID}
id: "verification-recovery_key" id: "verification-recovery_key"
- inputText: ${MAESTRO_RECOVERY_KEY} - inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard - hideKeyboard
- tapOn: "Confirm" - tapOn: "Continue"
- extendedWaitUntil:
visible: "Device verified"
timeout: 10000
- tapOn: "Continue"

View file

@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Confirm that it's you" visible: "Confirm that it's you"
timeout: 10000 timeout: 20000

View file

@ -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) Changes in Element X v0.4.7 (2024-03-26)
======================================== ========================================

View file

@ -19,6 +19,7 @@ package io.element.android.x
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -31,7 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
@ -60,7 +60,7 @@ class MainActivity : NodeActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appBindings = bindings() appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this) appBindings.lockScreenService().handleSecureFlag(this)
WindowCompat.setDecorFitsSystemWindows(window, false) enableEdgeToEdge()
setContent { setContent {
MainContent(appBindings) MainContent(appBindings)
} }

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -58,6 +59,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -92,6 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers() fetchRoomMembers()
trackVisitedRoom()
}, },
onResume = { onResume = {
appCoroutineScope.launch { 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 { private fun fetchRoomMembers() = lifecycleScope.launch {
inputs.room.updateMembers() inputs.room.updateMembers()
} }

View file

@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver 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.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -101,6 +102,7 @@ class RoomFlowNodeTest {
roomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope, appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(),
) )
@Test @Test

View file

@ -1 +0,0 @@
Move session verification to the after login flow and make it mandatory.

View file

@ -1 +0,0 @@
Add a notification troubleshoot screen

View file

@ -1 +0,0 @@
Make completed poll more clearly visible

View file

@ -1 +0,0 @@
Fix analytics issue around room considered as space by mistake.

View file

@ -1 +0,0 @@
Fix crash observed when going back to the room list.

View file

@ -1 +0,0 @@
Hide Event org.matrix.msc3401.call.member on the timeline.

View file

@ -1 +0,0 @@
Add action to copy permalink

View 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

View file

@ -17,19 +17,14 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents 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.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.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart 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.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize 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.components.TextButton
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -82,6 +77,7 @@ fun AnalyticsOptInView(
.fillMaxSize() .fillMaxSize()
.systemBarsPadding() .systemBarsPadding()
.imePadding(), .imePadding(),
background = { OnboardingBackground() },
header = { AnalyticsOptInHeader(state, onClickTerms) }, header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() }, content = { AnalyticsOptInContent() },
footer = { footer = {
@ -103,11 +99,11 @@ private fun AnalyticsOptInHeader(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
IconTitleSubtitleMolecule( PageTitle(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconImageVector = Icons.Filled.Poll iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
) )
val text = buildAnnotatedStringWithStyledPart( val text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms, 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 @Composable
private fun AnalyticsOptInContent() { private fun AnalyticsOptInContent() {
Box( Box(
@ -162,20 +145,20 @@ private fun AnalyticsOptInContent() {
items = persistentListOf( items = persistentListOf(
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage), message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_settings), message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
), ),
textStyle = ElementTheme.typography.fontBodyMdMedium, textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.textPrimary, iconTint = ElementTheme.colors.iconSuccessPrimary,
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
) )
} }
} }

View file

@ -69,6 +69,8 @@ dependencies {
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.SelectionMode 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.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
override val values: Sequence<UserListState> override val values: Sequence<UserListState>
get() = sequenceOf( get() = sequenceOf(
aUserListState(), aUserListState(),
aUserListState().copy( aUserListState(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false, isSearchActive = false,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
), ),
aUserListState().copy( aUserListState(
searchResults = SearchBarResultState.Results( searchResults = SearchBarResultState.Results(
aMatrixUserList() aMatrixUserList()
.mapIndexed { index, matrixUser -> .mapIndexed { index, matrixUser ->
@ -46,6 +47,9 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true, isSearchActive = true,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
) ),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
) )
} }

View file

@ -16,10 +16,8 @@
package io.element.android.features.createroom.impl.addpeople 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -64,21 +62,16 @@ fun AddPeopleView(
) )
} }
) { padding -> ) { padding ->
Column( UserListView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) { state = state,
UserListView( showBackButton = false,
modifier = Modifier onUserSelected = {},
.fillMaxWidth(), onUserDeselected = {},
state = state, )
showBackButton = false,
onUserSelected = { },
onUserDeselected = {},
)
}
} }
} }

View file

@ -19,17 +19,27 @@ package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents 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.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.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 @Composable
fun UserListView( 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()
}
}
}
}
}
} }
} }

View file

@ -17,9 +17,12 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -28,7 +31,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
override val values: Sequence<CreateRoomRootState> override val values: Sequence<CreateRoomRootState>
get() = sequenceOf( get() = sequenceOf(
aCreateRoomRootState(), aCreateRoomRootState(),
aCreateRoomRootState().copy( aCreateRoomRootState(
startDmAction = AsyncAction.Loading, startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
) )
} }
), ),
aCreateRoomRootState().copy( aCreateRoomRootState(
startDmAction = AsyncAction.Failure(Throwable("error")), startDmAction = AsyncAction.Failure(Throwable("error")),
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
@ -50,12 +53,22 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
) )
} }
), ),
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = aRecentDirectRoomList()
)
),
) )
} }
fun aCreateRoomRootState() = CreateRoomRootState( fun aCreateRoomRootState(
eventSink = {}, applicationName: String = "Element X Preview",
applicationName = "Element X Preview", userListState: UserListState = aUserListState(),
startDmAction = AsyncAction.Uninitialized, startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
userListState = aUserListState(), eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
eventSink = eventSink,
) )

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
fun CreateRoomRootView( fun CreateRoomRootView(
@ -77,7 +81,11 @@ fun CreateRoomRootView(
) { ) {
UserListView( UserListView(
modifier = Modifier.fillMaxWidth(), 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 = { onUserSelected = {
state.eventSink(CreateRoomRootEvents.StartDM(it)) state.eventSink(CreateRoomRootEvents.StartDM(it))
}, },
@ -89,6 +97,7 @@ fun CreateRoomRootView(
state = state, state = state,
onNewRoomClicked = onNewRoomClicked, onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = onInviteFriendsClicked, onInvitePeopleClicked = onInviteFriendsClicked,
onDmClicked = onOpenDM,
) )
} }
} }
@ -106,7 +115,7 @@ fun CreateRoomRootView(
onRetry = { onRetry = {
state.userListState.selectedUsers.firstOrNull() state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) } ?.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) ?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
}, },
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
@ -139,18 +148,43 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState, state: CreateRoomRootState,
onNewRoomClicked: () -> Unit, onNewRoomClicked: () -> Unit,
onInvitePeopleClicked: () -> Unit, onInvitePeopleClicked: () -> Unit,
onDmClicked: (RoomId) -> Unit,
) { ) {
Column { LazyColumn {
CreateRoomActionButton( item {
iconRes = CompoundDrawables.ic_compound_plus, CreateRoomActionButton(
text = stringResource(id = R.string.screen_create_room_action_create_room), iconRes = CompoundDrawables.ic_compound_plus,
onClick = onNewRoomClicked, 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), item {
onClick = onInvitePeopleClicked, 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,
)
}
}
}
} }
} }

View file

@ -30,6 +30,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope 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.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -41,6 +44,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs, @Assisted val args: UserListPresenterArgs,
@Assisted val userRepository: UserRepository, @Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore, @Assisted val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
) : UserListPresenter { ) : UserListPresenter {
@AssistedFactory @AssistedFactory
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@ -54,6 +58,10 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): UserListState { override fun present(): UserListState {
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
LaunchedEffect(Unit) {
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
@ -82,6 +90,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader, showSearchLoader = showSearchLoader,
selectionMode = args.selectionMode, selectionMode = args.selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = { event -> eventSink = { event ->
when (event) { when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active

View file

@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.userlist package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -28,6 +29,7 @@ data class UserListState(
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, val selectionMode: SelectionMode,
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
val eventSink: (UserListEvents) -> Unit, val eventSink: (UserListEvents) -> Unit,
) { ) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple

View file

@ -18,54 +18,82 @@ package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class UserListStateProvider : PreviewParameterProvider<UserListState> { open class UserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState> override val values: Sequence<UserListState>
get() = sequenceOf( get() = sequenceOf(
aUserListState(), aUserListState(),
aUserListState().copy( aUserListState(
isSearchActive = false, isSearchActive = false,
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
), ),
aUserListState().copy(isSearchActive = true), aUserListState(isSearchActive = true),
aUserListState().copy(isSearchActive = true, searchQuery = "someone"), aUserListState(isSearchActive = true, searchQuery = "someone"),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
), ),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
), ),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound() 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( fun aUserListState(
isSearchActive = false, searchQuery: String = "",
searchQuery = "", isSearchActive: Boolean = false,
searchResults = SearchBarResultState.Initial(), searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
selectedUsers = persistentListOf(), selectedUsers: List<MatrixUser> = emptyList(),
selectionMode = SelectionMode.Single, showSearchLoader: Boolean = false,
showSearchLoader = false, selectionMode: SelectionMode = SelectionMode.Single,
eventSink = {} 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 aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.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)
}

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
@ -45,6 +46,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -66,6 +68,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Multiple), UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -87,6 +90,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -123,6 +127,7 @@ class DefaultUserListPresenterTests {
), ),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -175,6 +180,7 @@ class DefaultUserListPresenterTests {
), ),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -200,6 +206,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

View file

@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R 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.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.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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.avatar.AvatarSize
@ -62,8 +64,9 @@ fun NotificationsOptInView(
HeaderFooterPage( HeaderFooterPage(
modifier = modifier modifier = modifier
.systemBarsPadding() .statusBarsPadding()
.fillMaxSize(), .fillMaxSize(),
background = { OnboardingBackground() },
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) }, header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) }, footer = { NotificationsOptInFooter(state) },
) { ) {
@ -75,11 +78,11 @@ fun NotificationsOptInView(
private fun NotificationsOptInHeader( private fun NotificationsOptInHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconTitleSubtitleMolecule( PageTitle(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title), title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle), subtitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = CompoundIcons.NotificationsSolid(), iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
) )
} }

View file

@ -19,11 +19,13 @@ package io.element.android.features.ftue.impl.sessionverification
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -55,13 +58,27 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onDone() 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
@ -72,8 +89,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey) backstack.push(NavTarget.EnterRecoveryKey)
} }
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() { override fun onDone() {
callback.onDone() plugins<Callback>().forEach { it.onDone() }
} }
}) })
.build() .build()
@ -81,11 +102,13 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
is NavTarget.EnterRecoveryKey -> { is NavTarget.EnterRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext) secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) .params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
.callback(object : SecureBackupEntryPoint.Callback { .callback(secureBackupEntryPointCallback)
override fun onDone() { .build()
callback.onDone() }
} is NavTarget.CreateNewRecoveryKey -> {
}) secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build() .build()
} }
} }

View file

@ -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.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ -56,7 +55,7 @@ class DefaultFtueService @Inject constructor(
} }
init { init {
sessionVerificationService.sessionVerifiedStatus sessionVerificationService.needsVerificationFlow
.onEach { updateState() } .onEach { updateState() }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@ -99,12 +98,8 @@ class DefaultFtueService @Inject constructor(
).any { it() } ).any { it() }
} }
private fun isSessionVerificationServiceReady(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
}
private fun isSessionNotVerified(): Boolean { private fun isSessionNotVerified(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified return sessionVerificationService.needsVerificationFlow.value
} }
private fun needsAnalyticsOptIn(): Boolean { private fun needsAnalyticsOptIn(): Boolean {
@ -132,7 +127,6 @@ class DefaultFtueService @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() { internal fun updateState() {
state.value = when { state.value = when {
!isSessionVerificationServiceReady() -> FtueState.Unknown
isAnyStepIncomplete() -> FtueState.Incomplete isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete else -> FtueState.Complete
} }

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string> <string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</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_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string>
<string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string> <string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string>
<string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string> <string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string>

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string> <string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</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_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string> <string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -90,6 +90,7 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@ -107,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification // Session verification
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.givenNeedsVerification(false)
// Notifications opt in // Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))

View file

@ -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.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions 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.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -166,6 +167,7 @@ internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
) )
} }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview( private fun ContentToPreview(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,

View file

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

View file

@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents { sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
data object RetrySend : RetrySendMenuEvents data object Retry : RetrySendMenuEvents
data object RemoveFailed : RetrySendMenuEvents data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents data object Dismiss : RetrySendMenuEvents
} }

View file

@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
is RetrySendMenuEvents.EventSelected -> { is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event selectedEvent = event.event
} }
RetrySendMenuEvents.RetrySend -> { RetrySendMenuEvents.Retry -> {
coroutineScope.launch { coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId -> selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId) room.retrySendMessage(transactionId)
@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
selectedEvent = null selectedEvent = null
} }
} }
RetrySendMenuEvents.RemoveFailed -> { RetrySendMenuEvents.Remove -> {
coroutineScope.launch { coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId -> selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId) room.cancelSend(transactionId)

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.retrysendmenu package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
} }
fun onRetry() { fun onRetry() {
state.eventSink(RetrySendMenuEvents.RetrySend) state.eventSink(RetrySendMenuEvents.Retry)
} }
fun onRemoveFailed() { fun onRemove() {
state.eventSink(RetrySendMenuEvents.RemoveFailed) state.eventSink(RetrySendMenuEvents.Remove)
} }
RetrySendMessageMenuBottomSheet( RetrySendMessageMenuBottomSheet(
modifier = modifier, modifier = modifier,
isVisible = isVisible, isVisible = isVisible,
onRetry = ::onRetry, onRetry = ::onRetry,
onRemoveFailed = ::onRemoveFailed, onRemove = ::onRemove,
onDismiss = ::onDismiss onDismiss = ::onDismiss
) )
} }
@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
private fun RetrySendMessageMenuBottomSheet( private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean, isVisible: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onRemoveFailed: () -> Unit, onRemove: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, 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 // FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
@Composable @Composable
private fun ColumnScope.RetrySendMenuContents( private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit, onRetry: () -> Unit,
onRemoveFailed: () -> Unit, onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
modifier = Modifier.clickable { modifier = Modifier.clickable {
coroutineScope.launch { coroutineScope.launch {
sheetState.hide() sheetState.hide()
onRemoveFailed() onRemove()
} }
} }
) )
} }
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview { internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed RetrySendMessageMenu(
Column { state = state,
RetrySendMenuContents( )
onRetry = {},
onRemoveFailed = {},
)
}
} }

View file

@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent() val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent) assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
} }
} }
@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent() val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss) initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1) assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null) val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0) assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1) assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1) assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null) val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(0) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1) assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }

View file

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

View file

@ -30,6 +30,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences 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.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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) = internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) } ElementPreviewDark { ContentToPreview(matrixUser) }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview(matrixUser: MatrixUser) { private fun ContentToPreview(matrixUser: MatrixUser) {
PreferencesRootView( PreferencesRootView(

View file

@ -78,10 +78,6 @@ class RoomDetailsPresenter @Inject constructor(
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } } val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } } val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
val isRoomModerationEnabled by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings) canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
if (canShowNotificationSettings.value) { if (canShowNotificationSettings.value) {
@ -147,7 +143,7 @@ class RoomDetailsPresenter @Inject constructor(
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite, isFavorite = isFavorite,
displayRolesAndPermissionsSettings = isRoomModerationEnabled && !room.isDm && isUserAdmin, displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -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.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -50,7 +48,6 @@ class RoomMemberListPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource, private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val featureFlagService: FeatureFlagService,
private val roomMembersModerationPresenter: RoomMembersModerationPresenter, private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
@Assisted private val navigator: RoomMemberListNavigator, @Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
@ -74,15 +71,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
value = room.canInvite().getOrElse { false } value = room.canInvite().getOrElse { false }
} }
val isRoomModerationEnabled by produceState(initialValue = false) { val roomModerationState = roomMembersModerationPresenter.present()
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
}
val roomModerationState = if (isRoomModerationEnabled) {
roomMembersModerationPresenter.present()
} else {
remember { roomMembersModerationPresenter.dummyState() }
}
// Ensure we load the latest data when entering this screen // Ensure we load the latest data when entering this screen
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View file

@ -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.R
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection 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.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton 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) = internal fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview(state: RoomMemberDetailsState) { private fun ContentToPreview(state: RoomMemberDetailsState) {
RoomMemberDetailsView( RoomMemberDetailsView(

View file

@ -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.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.finally import io.element.android.libraries.core.extensions.finally
import io.element.android.libraries.di.RoomScope 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -51,7 +49,6 @@ import javax.inject.Inject
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultRoomMembersModerationPresenter @Inject constructor( class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : RoomMembersModerationPresenter { ) : RoomMembersModerationPresenter {
@ -61,9 +58,8 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
private suspend fun canKick() = room.canKick().getOrDefault(false) private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean { override suspend fun canDisplayModerationActions(): Boolean {
val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
val isDm = room.isDm && room.isEncrypted val isDm = room.isDm && room.isEncrypted
return isRoomModerationEnabled && !isDm && (canBan() || canKick()) return !isDm && (canBan() || canKick())
} }
@Composable @Composable
@ -76,7 +72,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) } val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val canDisplayBannedUsers by produceState(initialValue = false) { val canDisplayBannedUsers by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan() value = !room.isDm && canBan()
} }
fun handleEvent(event: RoomMembersModerationEvents) { fun handleEvent(event: RoomMembersModerationEvents) {

View file

@ -29,9 +29,11 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.RoomMember
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -47,14 +49,22 @@ class RolesAndPermissionsPresenter @Inject constructor(
override fun present(): RolesAndPermissionsState { override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState(initial = null) 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 { val moderatorCount by remember {
derivedStateOf { derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.MODERATOR) roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.MODERATOR)
} }
} }
val adminCount by remember { val adminCount by remember {
derivedStateOf { derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.ADMIN) roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.ADMIN)
} }
} }
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@ -108,11 +118,9 @@ class RolesAndPermissionsPresenter @Inject constructor(
} }
} }
private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int { private fun MatrixRoomInfo?.userCountWithRole(joinedRoomMemberIds: List<UserId>, role: RoomMember.Role): Int {
return if (this != null) { return this?.userPowerLevels.orEmpty().count { (userId, level) ->
userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role } RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds
} else {
0
} }
} }
} }

View file

@ -16,12 +16,12 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles 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 { sealed interface ChangeRolesEvent {
data object ToggleSearchActive : ChangeRolesEvent data object ToggleSearchActive : ChangeRolesEvent
data class QueryChanged(val query: String?) : 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 Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent data object CancelExit : ChangeRolesEvent

View file

@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
ChangeRolesView( ChangeRolesView(
modifier = modifier, modifier = modifier,
state = state, state = state,
onBackPressed = this::navigateUp, navigateUp = this::navigateUp,
) )
} }
} }

View file

@ -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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember 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.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.libraries.matrix.api.user.MatrixUser
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -73,7 +75,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
var query by rememberSaveable { mutableStateOf<String?>(null) } var query by rememberSaveable { mutableStateOf<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember { var searchResults by remember {
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial()) mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
} }
val selectedUsers = remember { val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) 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 // 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 } } 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 // 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() selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
} }
.launchIn(this) .launchIn(this)
@ -101,8 +103,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
LaunchedEffect(query, roomMemberState) { LaunchedEffect(query, roomMemberState) {
val results = dataSource val results = dataSource
.search(query.orEmpty()) .search(query.orEmpty())
.sorted() .groupedByRole()
println(results)
searchResults = if (results.isEmpty()) { searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound() SearchBarResultState.NoResultsFound()
} else { } else {
@ -129,11 +132,11 @@ class ChangeRolesPresenter @AssistedInject constructor(
} }
is ChangeRolesEvent.UserSelectionToggled -> { is ChangeRolesEvent.UserSelectionToggled -> {
val newList = selectedUsers.value.toMutableList() 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) { if (index >= 0) {
newList.removeAt(index) newList.removeAt(index)
} else { } else {
newList.add(event.roomMember.toMatrixUser()) newList.add(event.matrixUser)
} }
selectedUsers.value = newList.toImmutableList() 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> { private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList() return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
} }
private fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
private fun CoroutineScope.save( private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>, usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>, selectedUsers: MutableState<ImmutableList<MatrixUser>>,

View file

@ -16,18 +16,20 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles 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.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class ChangeRolesState( data class ChangeRolesState(
val role: RoomMember.Role, val role: RoomMember.Role,
val query: String?, val query: String?,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>, val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean, val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>, val exitState: AsyncAction<Unit>,
@ -35,3 +37,21 @@ data class ChangeRolesState(
val canChangeMemberRole: (UserId) -> Boolean, val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit, 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()
}

View file

@ -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.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember 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.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -32,7 +33,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState> override val values: Sequence<ChangeRolesState>
get() = sequenceOf( get() = sequenceOf(
aChangeRolesState(), aChangeRolesState(),
aChangeRolesState(role = RoomMember.Role.MODERATOR), aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false), aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(), aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy( aChangeRolesStateWithSelectedUsers().copy(
@ -41,7 +42,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy( aChangeRolesStateWithSelectedUsers().copy(
query = "Alice", query = "Alice",
isSearchActive = true, isSearchActive = true,
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()), searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
selectedUsers = aMatrixUserList().take(1).toImmutableList(), selectedUsers = aMatrixUserList().take(1).toImmutableList(),
), ),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming), aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
@ -56,12 +57,13 @@ internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN, role: RoomMember.Role = RoomMember.Role.ADMIN,
query: String? = null, query: String? = null,
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(), searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false, hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized, exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized, savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true }, canRemoveMember: (UserId) -> Boolean = { true },
eventSink: (ChangeRolesEvent) -> Unit = {},
) = ChangeRolesState( ) = ChangeRolesState(
role = role, role = role,
query = query, query = query,
@ -72,12 +74,22 @@ internal fun aChangeRolesState(
exitState = exitState, exitState = exitState,
savingState = savingState, savingState = savingState,
canChangeMemberRole = canRemoveMember, canChangeMemberRole = canRemoveMember,
eventSink = {}, eventSink = eventSink,
) )
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState( internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
selectedUsers = aMatrixUserList().toImmutableList(), 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, hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") }, canRemoveMember = { it != UserId("@alice:server.org") },
) )

View file

@ -26,8 +26,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding 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.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R 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.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog 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.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator 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.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState 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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog 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.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember 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.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.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChangeRolesView( fun ChangeRolesView(
state: ChangeRolesState, state: ChangeRolesState,
onBackPressed: () -> Unit, navigateUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed) val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp)
BackHandler { BackHandler(enabled = !state.isSearchActive) {
if (state.isSearchActive) { state.eventSink(ChangeRolesEvent.Exit)
state.eventSink(ChangeRolesEvent.ToggleSearchActive)
} else {
state.eventSink(ChangeRolesEvent.Exit)
}
} }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -129,7 +134,9 @@ fun ChangeRolesView(
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
SearchBar( SearchBar(
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone), placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
query = state.query.orEmpty(), query = state.query.orEmpty(),
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) }, onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
@ -138,12 +145,12 @@ fun ChangeRolesView(
resultState = state.searchResults, resultState = state.searchResults,
) { members -> ) { members ->
SearchResultsList( SearchResultsList(
isSearchActive = true, currentRole = state.role,
lazyListState = lazyListState, lazyListState = lazyListState,
searchResults = members, searchResults = members,
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole, canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) }, onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
selectedUsersList = {}, selectedUsersList = {},
) )
} }
@ -154,18 +161,18 @@ fun ChangeRolesView(
) { ) {
Column { Column {
SearchResultsList( SearchResultsList(
isSearchActive = false, currentRole = state.role,
lazyListState = lazyListState, lazyListState = lazyListState,
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(), searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole, canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) }, onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
selectedUsersList = { users -> selectedUsersList = { users ->
SelectedUsersRowList( SelectedUsersRowList(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
selectedUsers = users, selectedUsers = users,
onUserRemoved = { onUserRemoved = {
state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId))) state.eventSink(ChangeRolesEvent.UserSelectionToggled(it))
}, },
canDeselect = { state.canChangeMemberRole(it.userId) }, canDeselect = { state.canChangeMemberRole(it.userId) },
) )
@ -181,7 +188,7 @@ fun ChangeRolesView(
AsyncActionView( AsyncActionView(
async = state.exitState, async = state.exitState,
onSuccess = { updatedOnBackPressed() }, onSuccess = { updatedNavigateUp() },
confirmationDialog = { confirmationDialog = {
ConfirmationDialog( ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title), title = stringResource(CommonStrings.dialog_unsaved_changes_title),
@ -229,8 +236,8 @@ fun ChangeRolesView(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SearchResultsList( private fun SearchResultsList(
isSearchActive: Boolean, currentRole: RoomMember.Role,
searchResults: ImmutableList<RoomMember>, searchResults: MembersByRole,
selectedUsers: ImmutableList<MatrixUser>, selectedUsers: ImmutableList<MatrixUser>,
canRemoveMember: (UserId) -> Boolean, canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit, onSelectionToggled: (RoomMember) -> Unit,
@ -243,43 +250,145 @@ private fun SearchResultsList(
item { item {
selectedUsersList(selectedUsers) selectedUsersList(selectedUsers)
} }
stickyHeader { if (searchResults.admins.isNotEmpty()) {
val textResId = if (isSearchActive) { stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
CommonStrings.common_search_results // Add a footer for the admin section in change role to moderator screen
} else { if (currentRole == RoomMember.Role.MODERATOR) {
R.string.screen_room_member_list_room_members_header_title item {
} Text(
Text( modifier = Modifier
modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
.background(ElementTheme.colors.bgCanvasDefault) text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer),
.padding(horizontal = 16.dp, vertical = 8.dp) color = ElementTheme.colors.textSecondary,
.fillMaxWidth(), style = ElementTheme.typography.fontBodySmRegular,
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) },
) )
} }
} else {
null
} }
MatrixUserRow( items(searchResults.admins, key = { it.userId }) { roomMember ->
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }), ListMemberItem(
matrixUser = MatrixUser( roomMember = roomMember,
userId = roomMember.userId, canRemoveMember = canRemoveMember,
displayName = roomMember.displayName, onSelectionToggled = onSelectionToggled,
avatarUrl = roomMember.avatarUrl, selectedUsers = selectedUsers
), )
trailingContent = trailingContent, }
)
} }
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 { ElementPreview {
ChangeRolesView( ChangeRolesView(
state = state, 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,
)
}
) )
} }
} }

View file

@ -17,8 +17,9 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions
import androidx.activity.compose.BackHandler 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -80,29 +81,35 @@ fun ChangeRoomPermissionsView(
) )
} }
) { padding -> ) { padding ->
Column(modifier = Modifier.padding(padding)) { LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
for ((index, permissionItem) in state.items.withIndex()) { for ((index, permissionItem) in state.items.withIndex()) {
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0) item {
SelectRoleItem( ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
permissionsItem = permissionItem, SelectRoleItem(
role = RoomMember.Role.ADMIN, permissionsItem = permissionItem,
currentPermissions = state.currentPermissions role = RoomMember.Role.ADMIN,
) { item, role -> currentPermissions = state.currentPermissions
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role)) ) { item, role ->
} state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
SelectRoleItem( }
permissionsItem = permissionItem, SelectRoleItem(
role = RoomMember.Role.MODERATOR, permissionsItem = permissionItem,
currentPermissions = state.currentPermissions role = RoomMember.Role.MODERATOR,
) { item, role -> currentPermissions = state.currentPermissions
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role)) ) { item, role ->
} state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
SelectRoleItem( }
permissionsItem = permissionItem, SelectRoleItem(
role = RoomMember.Role.USER, permissionsItem = permissionItem,
currentPermissions = state.currentPermissions role = RoomMember.Role.USER,
) { item, role -> currentPermissions = state.currentPermissions
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role)) ) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
} }
} }
} }

View file

@ -31,6 +31,7 @@
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string> <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">"%1$s (Pending)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(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_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_section_administrators">"Admins"</string> <string name="screen_room_change_role_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</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_room_name_label">"Room name"</string>
<string name="screen_room_details_security_title">"Security"</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_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_topic_title">"Topic"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string> <string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_ban_member_confirmation_action">"Ban"</string> <string name="screen_room_member_list_ban_member_confirmation_action">"Ban"</string>

View file

@ -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.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -241,14 +238,12 @@ private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixRoom: MatrixRoom = FakeMatrixRoom(), matrixRoom: MatrixRoom = FakeMatrixRoom(),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers), roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(), moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { } navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
) = RoomMemberListPresenter( ) = RoomMemberListPresenter(
room = matrixRoom, room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource, roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
featureFlagService = featureFlagService,
roomMembersModerationPresenter = moderationPresenter, roomMembersModerationPresenter = moderationPresenter,
navigator = navigator navigator = navigator
) )

View file

@ -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.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember 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.RoomMembershipState
@ -45,13 +43,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultRoomMembersModerationPresenterTests { 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 @Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest { fun `canDisplayModerationActions - when room is DM is false`() = runTest {
val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply { val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
@ -309,13 +300,11 @@ class DefaultRoomMembersModerationPresenterTests {
private fun TestScope.createDefaultRoomMembersModerationPresenter( private fun TestScope.createDefaultRoomMembersModerationPresenter(
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(), matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): DefaultRoomMembersModerationPresenter { ): DefaultRoomMembersModerationPresenter {
return DefaultRoomMembersModerationPresenter( return DefaultRoomMembersModerationPresenter(
room = matrixRoom, room = matrixRoom,
featureFlagService = featureFlagService,
dispatchers = dispatchers, dispatchers = dispatchers,
analyticsService = analyticsService, analyticsService = analyticsService,
) )

View file

@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration 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.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesPresenter 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.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.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
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -106,15 +106,19 @@ class ChangeRolesPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty() val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults).hasSize(10) assertThat(initialResults?.members).hasSize(8)
assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1)
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice")) initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
skipItems(1) skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty() val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(searchResults).hasSize(1) assertThat(searchResults?.admins).hasSize(1)
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID) 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() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty() val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults).hasSize(10) assertThat(initialResults?.members).hasSize(8)
assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1)
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList())) room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
skipItems(1) skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty() val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(searchResults).hasSize(1) assertThat(searchResults?.admins).hasSize(1)
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID) 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() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) 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) 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) assertThat(awaitItem().selectedUsers).hasSize(1)
} }
} }
@ -177,13 +185,13 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.selectedUsers).hasSize(1) 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()) { with(awaitItem()) {
assertThat(selectedUsers).hasSize(2) assertThat(selectedUsers).hasSize(2)
assertThat(hasPendingChanges).isTrue() assertThat(hasPendingChanges).isTrue()
} }
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2))) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
with(awaitItem()) { with(awaitItem()) {
assertThat(selectedUsers).hasSize(1) assertThat(selectedUsers).hasSize(1)
assertThat(hasPendingChanges).isFalse() assertThat(hasPendingChanges).isFalse()
@ -226,7 +234,7 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) 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) awaitItem().eventSink(ChangeRolesEvent.Exit)
val confirmingState = awaitItem() val confirmingState = awaitItem()
@ -252,7 +260,7 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) 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() val updatedState = awaitItem()
assertThat(updatedState.hasPendingChanges).isTrue() assertThat(updatedState.hasPendingChanges).isTrue()
skipItems(1) skipItems(1)
@ -279,8 +287,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) 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) awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming) assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming)
@ -304,7 +311,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) 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) awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem() val confirmingState = awaitItem()
@ -334,7 +341,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) 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) awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
@ -357,7 +364,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1) 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) awaitItem().eventSink(ChangeRolesEvent.Save)
val failedState = awaitItem() val failedState = awaitItem()

View file

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

View file

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

View file

@ -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. <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> For now, you can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"You dont have favourite chats yet"</string> <string name="screen_roomlist_filter_favourites_empty_state_title">"You dont 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_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_subtitle">"You can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"You dont have chats for this selection"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"You dont have chats for this selection"</string>

View file

@ -31,6 +31,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data object EnterRecoveryKey : InitialTarget data object EnterRecoveryKey : InitialTarget
@Parcelize
data object CreateNewRecoveryKey : InitialTarget
} }
data class Params(val initialElement: InitialTarget) : NodeInputs data class Params(val initialElement: InitialTarget) : NodeInputs
@ -38,6 +41,7 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface Callback : Plugin { interface Callback : Plugin {
fun onCreateNewRecoveryKey()
fun onDone() fun onDone()
} }

View file

@ -30,6 +30,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint 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.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode 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) { initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) {
SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root
SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey
SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey -> NavTarget.CreateNewRecoveryKey
}, },
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
@ -74,6 +76,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
} }
private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull() private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull()
@ -134,6 +139,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
} }
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext, plugins = listOf(callback)) createNode<SecureBackupEnterRecoveryKeyNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.CreateNewRecoveryKey -> {
createNode<CreateNewRecoveryKeyNode>(buildContext)
}
} }
} }

View file

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

View file

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

View file

@ -83,7 +83,7 @@ private fun ColumnScope.Buttons(
state: SecureBackupEnterRecoveryKeyState, state: SecureBackupEnterRecoveryKeyState,
) { ) {
Button( Button(
text = stringResource(id = CommonStrings.action_confirm), text = stringResource(id = CommonStrings.action_continue),
enabled = state.isSubmitEnabled, enabled = state.isSubmitEnabled,
showProgress = state.submitAction.isLoading(), showProgress = state.submitAction.isLoading(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -70,7 +70,10 @@ internal fun RecoveryKeyView(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( 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), modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
) )

View file

@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Ваша рэзервовая копія чата зараз не сінхранізавана."</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">"Наладзьце аднаўленне"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Атрымайце доступ да зашыфраваных паведамленняў, калі вы страціце ўсе свае прылады або выйдзеце з сістэмы %1$s усюды."</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_action_turn_off">"Адключыць"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."</string> <string name="screen_key_backup_disable_confirmation_description">"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"</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_generate_key_description">"Пераканайцеся, што вы можаце захаваць ключ аднаўлення ў бяспечным месцы"</string>
<string name="screen_recovery_key_change_success">"Ключ аднаўлення зменены"</string> <string name="screen_recovery_key_change_success">"Ключ аднаўлення зменены"</string>
<string name="screen_recovery_key_change_title">"Змяніць ключ аднаўлення?"</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_content">"Паўтарыце спробу, каб пацвердзіць доступ да рэзервовай копіі чата."</string>
<string name="screen_recovery_key_confirm_error_title">"Няправільны ключ аднаўлення"</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_key_placeholder">"Увесці…"</string>
<string name="screen_recovery_key_confirm_success">"Ключ аднаўлення пацверджаны"</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_copied_to_clipboard">"Ключ аднаўлення скапіраваны"</string>
<string name="screen_recovery_key_generating_key">"Стварэнне…"</string> <string name="screen_recovery_key_generating_key">"Стварэнне…"</string>
<string name="screen_recovery_key_save_action">"Захаваць ключ аднаўлення"</string> <string name="screen_recovery_key_save_action">"Захаваць ключ аднаўлення"</string>

View file

@ -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_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">"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_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_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_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> <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_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_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_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_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_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_key_placeholder">"Zadejte…"</string>
<string name="screen_recovery_key_confirm_success">"Klíč pro obnovení potvrzen"</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> <string name="screen_recovery_key_confirm_title">"Potvrďte klíč pro obnovení"</string>

View file

@ -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_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">"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_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_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_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> <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_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_success">"Wiederherstellungsschlüssel geändert"</string>
<string name="screen_recovery_key_change_title">"Wiederherstellungsschlüssel ändern?"</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_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_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_key_placeholder">"Eingeben…"</string>
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</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_copied_to_clipboard">"Wiederherstellungsschlüssel kopiert"</string>
<string name="screen_recovery_key_generating_key">"Generieren…"</string> <string name="screen_recovery_key_generating_key">"Generieren…"</string>
<string name="screen_recovery_key_save_action">"Wiederherstellungsschlüssel speichern"</string> <string name="screen_recovery_key_save_action">"Wiederherstellungsschlüssel speichern"</string>

View file

@ -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_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">"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_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_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_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> <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_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_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_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_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_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_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_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_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_generating_key">"Előállítás…"</string>
<string name="screen_recovery_key_save_action">"Helyreállítási kulcs mentése"</string> <string name="screen_recovery_key_save_action">"Helyreállítási kulcs mentése"</string>

View file

@ -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_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">"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_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_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_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> <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_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_success">"Kunci pemulihan diganti"</string>
<string name="screen_recovery_key_change_title">"Ubah kunci pemulihan?"</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_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_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_key_placeholder">"Masukkan…"</string>
<string name="screen_recovery_key_confirm_success">"Kunci pemulihan dikonfirmasi"</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_copied_to_clipboard">"Kunci pemulihan disalin"</string>
<string name="screen_recovery_key_generating_key">"Membuat…"</string> <string name="screen_recovery_key_generating_key">"Membuat…"</string>
<string name="screen_recovery_key_save_action">"Simpan kunci pemulihan"</string> <string name="screen_recovery_key_save_action">"Simpan kunci pemulihan"</string>

View file

@ -4,11 +4,28 @@
<string name="screen_chat_backup_key_backup_action_enable">"Включить резервное копирование"</string> <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_description">"Резервное копирование гарантирует, что вы не потеряете историю сообщений. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Резервное копирование"</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_change">
<string name="screen_chat_backup_recovery_action_confirm">"Подтвердить ключ восстановления"</string> "Изменить "
<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_confirm_description">"Резервная копия чата в настоящее время не синхронизирована."</string>
<string name="screen_chat_backup_recovery_action_setup">"Настроить восстановление"</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_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_action_turn_off">"Выключить"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string> <string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</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_description_point_2">"Потерять доступ к зашифрованным сообщениям, если вы вышли из %1$s любой точки мира"</string>
<string name="screen_key_backup_disable_title">"Вы действительно хотите отключить резервное копирование?"</string> <string name="screen_key_backup_disable_title">"Вы действительно хотите отключить резервное копирование?"</string>
<string name="screen_recovery_key_change_description">"Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать."</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_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_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_content">"Пожалуйста, попробуйте еще раз, чтобы подтвердить доступ к резервной копии чата."</string>
<string name="screen_recovery_key_confirm_error_title">"Неверный ключ восстановления"</string> <string name="screen_recovery_key_confirm_error_title">
<string name="screen_recovery_key_confirm_key_description">"Введите 48 значный код."</string> "Неверный "
<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_key_placeholder">"Вход…"</string>
<string name="screen_recovery_key_confirm_success">"Ключ восстановления подтвержден"</string> <string name="screen_recovery_key_confirm_success">
<string name="screen_recovery_key_confirm_title">"Подтвердите ключ восстановления"</string> <b>"Ключ восстановления"</b>
<string name="screen_recovery_key_copied_to_clipboard">"Ключ восстановления скопирован"</string> " подтвержден"
</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_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_description">"Запишите ключ восстановления в безопасном месте или сохраните его в менеджере паролей."</string>
<string name="screen_recovery_key_save_key_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_description">"После этого шага вы не сможете получить доступ к новому ключу восстановления."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Вы сохранили ключ восстановления?"</string> <string name="screen_recovery_key_setup_confirmation_title">"Вы сохранили ключ восстановления?"</string>
<string name="screen_recovery_key_setup_description">"Резервная копия чата защищена ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав «Изменить ключ восстановления»."</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_generate_key_description">"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"</string>
<string name="screen_recovery_key_setup_success">"Настройка восстановления выполнена успешно"</string> <string name="screen_recovery_key_setup_success">"Настройка восстановления выполнена успешно"</string>
<string name="screen_recovery_key_setup_title">"Настроить восстановление"</string> <string name="screen_recovery_key_setup_title">"Настроить восстановление"</string>

View file

@ -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_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">"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_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_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_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> <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_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_success">"Kľúč na obnovenie bol zmenený"</string>
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</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_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_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_key_placeholder">"Zadať…"</string>
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</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_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
<string name="screen_recovery_key_generating_key">"Generovanie…"</string> <string name="screen_recovery_key_generating_key">"Generovanie…"</string>
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string> <string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>

View file

@ -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_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">"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_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_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_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> <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_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_success">"Recovery key changed"</string>
<string name="screen_recovery_key_change_title">"Change recovery key?"</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_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_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_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</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_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string> <string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string> <string name="screen_recovery_key_save_action">"Save recovery key"</string>

View file

@ -38,6 +38,7 @@ fun aSignedOutState() = SignedOutState(
fun aSessionData( fun aSessionData(
sessionId: SessionId = SessionId("@alice:server.org"), sessionId: SessionId = SessionId("@alice:server.org"),
isTokenValid: Boolean = false, isTokenValid: Boolean = false,
needsVerification: Boolean = false,
): SessionData { ): SessionData {
return SessionData( return SessionData(
userId = sessionId.value, userId = sessionId.value,
@ -51,5 +52,6 @@ fun aSessionData(
isTokenValid = isTokenValid, isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN, loginType = LoginType.UNKNOWN,
passphrase = null, passphrase = null,
needsVerification = needsVerification,
) )
} }

View file

@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onEnterRecoveryKey() fun onEnterRecoveryKey()
fun onCreateNewRecoveryKey()
fun onDone() fun onDone()
} }
} }

View file

@ -34,17 +34,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: VerifySelfSessionPresenter, private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onEnterRecoveryKey() { private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onEnterRecoveryKey()
}
}
private fun onDone() {
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onDone()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -52,8 +42,9 @@ class VerifySelfSessionNode @AssistedInject constructor(
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onEnterRecoveryKey = ::onEnterRecoveryKey, onEnterRecoveryKey = callback::onEnterRecoveryKey,
onFinished = ::onDone, onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey,
onFinished = callback::onDone,
) )
} }
} }

View file

@ -23,10 +23,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.freeletics.flowredux.compose.rememberStateAndDispatch import com.freeletics.flowredux.compose.rememberStateAndDispatch
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
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.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -35,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState 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 sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine, private val stateMachine: VerifySelfSessionStateMachine,
private val buildMeta: BuildMeta,
) : Presenter<VerifySelfSessionState> { ) : Presenter<VerifySelfSessionState> {
@Composable @Composable
override fun present(): VerifySelfSessionState { override fun present(): VerifySelfSessionState {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state // Force reset, just in case the service was left in a broken state
sessionVerificationService.reset() sessionVerificationService.reset()
} }
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch() val stateAndDispatch = stateMachine.rememberStateAndDispatch()
var skipVerification by remember { mutableStateOf(false) }
val needsVerification by sessionVerificationService.needsVerificationFlow.collectAsState()
val verificationFlowStep by remember { val verificationFlowStep by remember {
derivedStateOf { derivedStateOf {
stateAndDispatch.state.value.toVerificationStep( when {
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE skipVerification -> VerifySelfSessionState.VerificationStep.Skipped
) needsVerification -> stateAndDispatch.state.value.toVerificationStep(
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
)
else -> VerifySelfSessionState.VerificationStep.Completed
}
} }
} }
// Start this after observing state machine // Start this after observing state machine
@ -72,10 +85,15 @@ class VerifySelfSessionPresenter @Inject constructor(
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset) VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
sessionVerificationService.saveVerifiedState(true)
skipVerification = true
}
} }
} }
return VerifySelfSessionState( return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep, verificationFlowStep = verificationFlowStep,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -85,7 +103,7 @@ class VerifySelfSessionPresenter @Inject constructor(
): VerifySelfSessionState.VerificationStep = ): VerifySelfSessionState.VerificationStep =
when (val machineState = this) { when (val machineState = this) {
StateMachineState.Initial, null -> { StateMachineState.Initial, null -> {
VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey) VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey, isLastDevice = encryptionService.isLastDevice.value)
} }
StateMachineState.RequestingVerification, StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification, StateMachineState.StartingSasVerification,

View file

@ -24,15 +24,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
@Immutable @Immutable
data class VerifySelfSessionState( data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep, val verificationFlowStep: VerificationStep,
val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit, val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) { ) {
@Stable @Stable
sealed interface VerificationStep { 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 Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep data object AwaitingOtherDeviceResponse : VerificationStep
data object Ready : VerificationStep data object Ready : VerificationStep
data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep data class Verifying(val data: SessionVerificationData, val state: AsyncData<Unit>) : VerificationStep
data object Completed : VerificationStep data object Completed : VerificationStep
data object Skipped : VerificationStep
} }
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji 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> { open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState> override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf( get() = sequenceOf(
aVerifySelfSessionState(), aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized) verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled verificationFlowStep = VerificationStep.Canceled
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready verificationFlowStep = VerificationStep.Ready
), ),
aVerifySelfSessionState( aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
), ),
aVerifySelfSessionState( 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 // Add other state here
) )
@ -63,10 +71,12 @@ private fun aDecimalsSessionVerificationData(
} }
internal fun aVerifySelfSessionState( internal fun aVerifySelfSessionState(
verificationFlowStep: VerifySelfSessionState.VerificationStep = VerifySelfSessionState.VerificationStep.Initial(false), verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false, isLastDevice = false),
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {}, eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState( ) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep, verificationFlowStep = verificationFlowStep,
displaySkipButton = displaySkipButton,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -28,8 +28,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource 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.Button
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton 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.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VerifySelfSessionView( fun VerifySelfSessionView(
state: VerifySelfSessionState, state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit, onEnterRecoveryKey: () -> Unit,
onCreateNewRecoveryKey: () -> Unit,
onFinished: () -> Unit, onFinished: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun resetFlow() { fun resetFlow() {
state.eventSink(VerifySelfSessionViewEvents.Reset) state.eventSink(VerifySelfSessionViewEvents.Reset)
} }
val updatedOnFinished by rememberUpdatedState(newValue = onFinished)
LaunchedEffect(state.verificationFlowStep, updatedOnFinished) {
if (state.verificationFlowStep is FlowStep.Skipped) {
updatedOnFinished()
}
}
BackHandler { BackHandler {
when (state.verificationFlowStep) { when (state.verificationFlowStep) {
is FlowStep.Canceled -> resetFlow() is FlowStep.Canceled -> resetFlow()
@ -81,6 +94,19 @@ fun VerifySelfSessionView(
val verificationFlowStep = state.verificationFlowStep val verificationFlowStep = state.verificationFlowStep
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, 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 = { header = {
HeaderContent(verificationFlowStep = verificationFlowStep) HeaderContent(verificationFlowStep = verificationFlowStep)
}, },
@ -89,6 +115,7 @@ fun VerifySelfSessionView(
screenState = state, screenState = state,
goBack = ::resetFlow, goBack = ::resetFlow,
onEnterRecoveryKey = onEnterRecoveryKey, onEnterRecoveryKey = onEnterRecoveryKey,
onCreateNewRecoveryKey = onCreateNewRecoveryKey,
onFinished = onFinished, onFinished = onFinished,
) )
} }
@ -104,6 +131,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
FlowStep.Canceled -> BigIcon.Style.AlertSolid FlowStep.Canceled -> BigIcon.Style.AlertSolid
FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
FlowStep.Completed -> BigIcon.Style.SuccessSolid FlowStep.Completed -> BigIcon.Style.SuccessSolid
is FlowStep.Skipped -> return
} }
val titleTextId = when (verificationFlowStep) { val titleTextId = when (verificationFlowStep) {
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title 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.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
} }
is FlowStep.Skipped -> return
} }
val subtitleTextId = when (verificationFlowStep) { val subtitleTextId = when (verificationFlowStep) {
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_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 FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
} }
is FlowStep.Skipped -> return
} }
PageTitle( PageTitle(
modifier = Modifier.padding(top = 60.dp),
iconStyle = iconStyle, iconStyle = iconStyle,
title = stringResource(id = titleTextId), title = stringResource(id = titleTextId),
subtitle = stringResource(id = subtitleTextId) subtitle = stringResource(id = subtitleTextId)
@ -137,9 +166,8 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
@Composable @Composable
private fun Content(flowState: FlowStep) { private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) { Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
when (flowState) { if (flowState is FlowStep.Verifying) {
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit ContentVerifying(flowState)
is FlowStep.Verifying -> ContentVerifying(flowState)
} }
} }
} }
@ -200,6 +228,7 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
private fun BottomMenu( private fun BottomMenu(
screenState: VerifySelfSessionState, screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit, onEnterRecoveryKey: () -> Unit,
onCreateNewRecoveryKey: () -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
onFinished: () -> Unit, onFinished: () -> Unit,
) { ) {
@ -210,12 +239,21 @@ private fun BottomMenu(
when (verificationViewState) { when (verificationViewState) {
is FlowStep.Initial -> { is FlowStep.Initial -> {
BottomMenu( if (verificationViewState.isLastDevice) {
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device), BottomMenu(
onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key), onPositiveButtonClicked = onEnterRecoveryKey,
onNegativeButtonClicked = 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 -> { is FlowStep.Canceled -> {
BottomMenu( BottomMenu(
@ -264,6 +302,7 @@ private fun BottomMenu(
onPositiveButtonClicked = onFinished, onPositiveButtonClicked = onFinished,
) )
} }
is FlowStep.Skipped -> return
} }
} }
@ -307,6 +346,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView( VerifySelfSessionView(
state = state, state = state,
onEnterRecoveryKey = {}, onEnterRecoveryKey = {},
onCreateNewRecoveryKey = {},
onFinished = {}, onFinished = {},
) )
} }

View file

@ -23,4 +23,5 @@ sealed interface VerifySelfSessionViewEvents {
data object DeclineVerification : VerifySelfSessionViewEvents data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents data object Reset : VerifySelfSessionViewEvents
data object SkipVerification : VerifySelfSessionViewEvents
} }

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_cancelled_subtitle">"Здаецца, нешта не так. Альбо час чакання запыту скончыўся, альбо запыт быў адхілены."</string>
<string name="screen_session_verification_compare_emojis_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_emojis_title">"Параўнайце эмодзі"</string>

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Porovnání emotikonů"</string>

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Emojis vergleichen"</string>

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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 dune 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_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_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> <string name="screen_session_verification_compare_emojis_title">"Comparez les émojis"</string>

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Emodzsik összehasonlítása"</string>

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Bandingkan emoji"</string>

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Confronta le emoji"</string>

View file

@ -1,12 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_cancelled_subtitle">"Похоже, что-то не так. Время ожидания запроса либо истекло, либо запрос был отклонен."</string>
<string name="screen_session_verification_compare_emojis_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_emojis_title">"Сравните емодзи"</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе."</string> <string name="screen_session_verification_compare_numbers_subtitle">"Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе."</string>
<string name="screen_session_verification_compare_numbers_title">"Сравните числа"</string> <string name="screen_session_verification_compare_numbers_title">"Сравните числа"</string>
<string name="screen_session_verification_complete_subtitle">"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное."</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_subtitle">"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы."</string>
<string name="screen_session_verification_open_existing_session_title">"Открыть существующий сеанс"</string> <string name="screen_session_verification_open_existing_session_title">"Открыть существующий сеанс"</string>
<string name="screen_session_verification_positive_button_canceled">"Повторить проверку"</string> <string name="screen_session_verification_positive_button_canceled">"Повторить проверку"</string>

View file

@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <string name="screen_session_verification_compare_emojis_title">"Porovnajte emotikony"</string>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_cancelled_subtitle">"Щось не так. Або час очікування запиту минув, або в запиті було відмовлено."</string>
<string name="screen_session_verification_compare_emojis_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_emojis_title">"Порівняти емодзі"</string>

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_cancelled_subtitle">"似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。"</string>
<string name="screen_session_verification_compare_emojis_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_emojis_title">"比對表情符號"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <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>

View file

@ -23,15 +23,19 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncData 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.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState 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.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState 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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -48,7 +52,21 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.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 @Test
fun `present - Handles requestVerification`() = runTest { fun `present - Handles requestVerification`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -79,13 +112,13 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Handles startSasVerification`() = runTest { fun `present - Handles startSasVerification`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification) eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response: // Await for other device response:
@ -104,7 +137,7 @@ class VerifySelfSessionPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel) eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents() expectNoEvents()
@ -113,7 +146,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - A failure when verifying cancels it`() = runTest { fun `present - A failure when verifying cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -130,7 +163,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest { 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) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -139,13 +172,13 @@ class VerifySelfSessionPresenterTests {
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification) awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java) assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
} }
} }
@Test @Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest { fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -158,7 +191,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -171,7 +204,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest { fun `present - Restart after cancelation returns to requesting verification`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -188,7 +221,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - Go back after cancelation returns to initial state`() = runTest { fun `present - Go back after cancelation returns to initial state`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -198,7 +231,7 @@ class VerifySelfSessionPresenterTests {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset) state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state // Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -208,7 +241,7 @@ class VerifySelfSessionPresenterTests {
val emojis = listOf( val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley") VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
) )
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -230,7 +263,7 @@ class VerifySelfSessionPresenterTests {
@Test @Test
fun `present - When verification is declined, the flow is canceled`() = runTest { fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = FakeSessionVerificationService() val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() 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( private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState(
fakeService: FakeSessionVerificationService, fakeService: FakeSessionVerificationService,
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState { ): VerifySelfSessionState {
var state = awaitItem() var state = awaitItem()
assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false)) assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response: // Await for other device response:
state = awaitItem() state = awaitItem()
@ -271,14 +331,23 @@ class VerifySelfSessionPresenterTests {
return state return state
} }
private fun unverifiedSessionService(): FakeSessionVerificationService {
return FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
}
}
private fun createVerifySelfSessionPresenter( private fun createVerifySelfSessionPresenter(
service: SessionVerificationService = FakeSessionVerificationService(), service: SessionVerificationService = unverifiedSessionService(),
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
): VerifySelfSessionPresenter { ): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter( return VerifySelfSessionPresenter(
sessionVerificationService = service, sessionVerificationService = service,
encryptionService = encryptionService, encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service, encryptionService), stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
buildMeta = buildMeta,
) )
} }
} }

View file

@ -17,17 +17,20 @@
package io.element.android.features.verifysession.impl package io.element.android.features.verifysession.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled 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.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -38,16 +41,12 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `back key pressed - when canceled resets the flow`() { fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
} }
@ -55,16 +54,12 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `back key pressed - when awaiting response cancels the verification`() { fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
} }
@ -72,16 +67,12 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `back key pressed - when ready to verify cancels the verification`() { fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
} }
@ -89,19 +80,15 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `back key pressed - when verifying and not loading declines the verification`() { fun `back key pressed - when verifying and not loading declines the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( data = aEmojisSessionVerificationData(),
data = aEmojisSessionVerificationData(), state = AsyncData.Uninitialized,
state = AsyncData.Uninitialized,
),
eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), eventSink = eventsRecorder
onFinished = EnsureNeverCalled(), ),
) )
}
rule.pressBackKey() rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
} }
@ -109,19 +96,28 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `back key pressed - when verifying and loading does nothing`() { fun `back key pressed - when verifying and loading does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( data = aEmojisSessionVerificationData(),
data = aEmojisSessionVerificationData(), state = AsyncData.Loading(),
state = AsyncData.Loading(),
),
eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), eventSink = eventsRecorder
onFinished = EnsureNeverCalled(), ),
) )
} 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() rule.pressBackKey()
eventsRecorder.assertEmpty() 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`() { fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onFinished = callback,
onEnterRecoveryKey = EnsureNeverCalled(), )
onFinished = callback,
)
}
rule.clickOn(CommonStrings.action_continue) rule.clickOn(CommonStrings.action_continue)
} }
} }
@ -149,36 +142,45 @@ class VerifySelfSessionViewTest {
fun `clicking on enter recovery key calls the expected callback`() { fun `clicking on enter recovery key calls the expected callback`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, false),
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onEnterRecoveryKey = callback,
onEnterRecoveryKey = callback, )
onFinished = EnsureNeverCalled(),
)
}
rule.clickOn(R.string.screen_session_verification_enter_recovery_key) 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 @Test
fun `clicking on they match emits the expected event`() { fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( data = aEmojisSessionVerificationData(),
data = aEmojisSessionVerificationData(), state = AsyncData.Uninitialized,
state = AsyncData.Uninitialized,
),
eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), eventSink = eventsRecorder
onFinished = EnsureNeverCalled(), ),
) )
}
rule.clickOn(R.string.screen_session_verification_they_match) rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification) eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification)
} }
@ -186,20 +188,60 @@ class VerifySelfSessionViewTest {
@Test @Test
fun `clicking on they do not match emits the expected event`() { fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>() val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent { rule.setVerifySelfSessionView(
VerifySelfSessionView( aVerifySelfSessionState(
aVerifySelfSessionState( verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying( data = aEmojisSessionVerificationData(),
data = aEmojisSessionVerificationData(), state = AsyncData.Uninitialized,
state = AsyncData.Uninitialized,
),
eventSink = eventsRecorder
), ),
onEnterRecoveryKey = EnsureNeverCalled(), eventSink = eventsRecorder
onFinished = EnsureNeverCalled(), ),
) )
}
rule.clickOn(R.string.screen_session_verification_they_dont_match) rule.clickOn(R.string.screen_session_verification_they_dont_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification) 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,
)
}
}
} }

View file

@ -3,7 +3,7 @@
[versions] [versions]
# Project # Project
android_gradle_plugin = "8.3.1" android_gradle_plugin = "8.3.2"
kotlin = "1.9.23" kotlin = "1.9.23"
ksp = "1.9.23-1.0.19" ksp = "1.9.23-1.0.19"
firebaseAppDistribution = "4.2.0" firebaseAppDistribution = "4.2.0"
@ -18,7 +18,7 @@ activity = "1.8.2"
media3 = "1.3.0" media3 = "1.3.0"
# Compose # Compose
compose_bom = "2024.03.00" compose_bom = "2024.04.00"
composecompiler = "1.5.11" composecompiler = "1.5.11"
# Coroutines # Coroutines
@ -33,12 +33,12 @@ test_core = "1.5.0"
#other #other
coil = "2.6.0" coil = "2.6.0"
datetime = "0.5.0" datetime = "0.5.0"
dependencyAnalysis = "1.30.0" dependencyAnalysis = "1.31.0"
serialization_json = "1.6.3" serialization_json = "1.6.3"
showkase = "1.0.2" showkase = "1.0.2"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.1" sqldelight = "2.0.2"
wysiwyg = "2.35.0" wysiwyg = "2.36.0"
telephoto = "0.9.0" telephoto = "0.9.0"
# DI # 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_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", 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" } 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" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } 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" 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" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2" molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2"
timber = "com.jakewharton.timber:timber:5.0.1" 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 = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", 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" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -176,7 +176,7 @@ kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.1.16" 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 # Note: only 0.19.0 will compile properly
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.15.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.15.0"

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.R
import io.element.android.libraries.designsystem.modifiers.blurCompat import io.element.android.libraries.designsystem.modifiers.blurCompat
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
@ -171,6 +172,7 @@ internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false) ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
} }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) { private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
Box( Box(

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