Merge branch 'develop' into feature/bma/leaveSpace
This commit is contained in:
commit
0e3efafa6d
117 changed files with 2158 additions and 287 deletions
|
|
@ -71,6 +71,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.permissions.noop)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
class CurrentUserWithNeighborsBuilder {
|
||||
/**
|
||||
* Build a list of [MatrixUser] containing the current user. If there are other sessions, the list
|
||||
* will contain 3 users, with the current user in the middle.
|
||||
* If there is only one other session, the list will contain twice the other user, to allow cycling.
|
||||
*/
|
||||
fun build(
|
||||
matrixUser: MatrixUser,
|
||||
sessions: List<SessionData>,
|
||||
): ImmutableList<MatrixUser> {
|
||||
// Sort by position to always have the same order (not depending on last account usage)
|
||||
return sessions.sortedBy { it.position }
|
||||
.map {
|
||||
if (it.userId == matrixUser.userId.value) {
|
||||
// Always use the freshest profile for the current user
|
||||
matrixUser
|
||||
} else {
|
||||
// Use the data from the DB
|
||||
MatrixUser(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
.let { sessionList ->
|
||||
// If the list has one item, there is no other session, return the list
|
||||
when (sessionList.size) {
|
||||
// Can happen when the user signs out (?)
|
||||
0 -> listOf(matrixUser)
|
||||
1 -> sessionList
|
||||
else -> {
|
||||
// Create a list with extra item at the start and end if necessary to have the current user in the middle
|
||||
// If the list is [A, B, C, D] and the current user is A we want to return [D, A, B]
|
||||
// If the current user is B, we want to return [A, B, C]
|
||||
// If the current user is C, we want to return [B, C, D]
|
||||
// If the current user is D, we want to return [C, D, A]
|
||||
// Special case: if there are only two users, we want to return [B, A, B] or [A, B, A] to allows cycling
|
||||
// between the two users.
|
||||
val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId }
|
||||
when (currentUserIndex) {
|
||||
// This can happen when the user signs out.
|
||||
// In this case, just return a singleton list with the current user.
|
||||
-1 -> listOf(matrixUser)
|
||||
0 -> listOf(sessionList.last()) + sessionList.take(2)
|
||||
sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first()
|
||||
else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toPersistentList()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface HomeEvents {
|
||||
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
|
||||
data class SwitchToAccount(val sessionId: SessionId) : HomeEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
|
@ -29,6 +30,10 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
|||
import io.element.android.libraries.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class HomePresenter(
|
||||
|
|
@ -41,10 +46,21 @@ class HomePresenter(
|
|||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Presenter<HomeState> {
|
||||
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||
|
||||
@Composable
|
||||
override fun present(): HomeState {
|
||||
val matrixUser = client.userProfile.collectAsState()
|
||||
val coroutineState = rememberCoroutineScope()
|
||||
val matrixUser by client.userProfile.collectAsState()
|
||||
val currentUserAndNeighbors by remember {
|
||||
combine(
|
||||
client.userProfile,
|
||||
sessionStore.sessionsFlow(),
|
||||
currentUserWithNeighborsBuilder::build,
|
||||
)
|
||||
}.collectAsState(initial = persistentListOf(matrixUser))
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
|
||||
val roomListState = roomListPresenter.present()
|
||||
|
|
@ -71,6 +87,9 @@ class HomePresenter(
|
|||
is HomeEvents.SelectHomeNavigationBarItem -> {
|
||||
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
||||
}
|
||||
is HomeEvents.SwitchToAccount -> coroutineState.launch {
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +101,7 @@ class HomePresenter(
|
|||
}
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
return HomeState(
|
||||
matrixUser = matrixUser.value,
|
||||
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
hasNetworkConnection = isOnline,
|
||||
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ import io.element.android.features.home.impl.spaces.HomeSpacesState
|
|||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class HomeState(
|
||||
val matrixUser: MatrixUser,
|
||||
/**
|
||||
* The current user of this session, in case of multiple accounts, will contains 3 items, with the
|
||||
* current user in the middle.
|
||||
*/
|
||||
val currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
val showAvatarIndicator: Boolean,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val currentHomeNavigationBarItem: HomeNavigationBarItem,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
open class HomeStateProvider : PreviewParameterProvider<HomeState> {
|
||||
override val values: Sequence<HomeState>
|
||||
|
|
@ -50,6 +51,7 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
|
|||
|
||||
internal fun aHomeState(
|
||||
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
|
||||
currentUserAndNeighbors: List<MatrixUser> = listOf(matrixUser),
|
||||
showAvatarIndicator: Boolean = false,
|
||||
hasNetworkConnection: Boolean = true,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
|
|
@ -61,7 +63,7 @@ internal fun aHomeState(
|
|||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||
eventSink: (HomeEvents) -> Unit = {}
|
||||
) = HomeState(
|
||||
matrixUser = matrixUser,
|
||||
currentUserAndNeighbors = currentUserAndNeighbors.toPersistentList(),
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
hasNetworkConnection = hasNetworkConnection,
|
||||
snackbarMessage = snackbarMessage,
|
||||
|
|
|
|||
|
|
@ -171,12 +171,15 @@ private fun HomeScaffold(
|
|||
topBar = {
|
||||
RoomListTopBar(
|
||||
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
|
||||
matrixUser = state.matrixUser,
|
||||
currentUserAndNeighbors = state.currentUserAndNeighbors,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
|
||||
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onAccountSwitch = {
|
||||
state.eventSink(HomeEvents.SwitchToAccount(it))
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
displayMenuItems = state.displayActions,
|
||||
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
|
||||
|
|
|
|||
|
|
@ -11,19 +11,25 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
|
|
@ -41,7 +47,6 @@ import io.element.android.features.home.impl.filters.RoomListFiltersView
|
|||
import io.element.android.features.home.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
||||
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.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
|
||||
|
|
@ -57,23 +62,29 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
|||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomListTopBar(
|
||||
title: String,
|
||||
matrixUser: MatrixUser,
|
||||
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
showAvatarIndicator: Boolean,
|
||||
areSearchResultsDisplayed: Boolean,
|
||||
onToggleSearch: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onAccountSwitch: (SessionId) -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
displayMenuItems: Boolean,
|
||||
displayFilters: Boolean,
|
||||
|
|
@ -83,10 +94,11 @@ fun RoomListTopBar(
|
|||
) {
|
||||
DefaultRoomListTopBar(
|
||||
title = title,
|
||||
matrixUser = matrixUser,
|
||||
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
areSearchResultsDisplayed = areSearchResultsDisplayed,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onAccountSwitch = onAccountSwitch,
|
||||
onSearchClick = onToggleSearch,
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
|
@ -102,11 +114,12 @@ fun RoomListTopBar(
|
|||
@Composable
|
||||
private fun DefaultRoomListTopBar(
|
||||
title: String,
|
||||
matrixUser: MatrixUser,
|
||||
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
showAvatarIndicator: Boolean,
|
||||
areSearchResultsDisplayed: Boolean,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onOpenSettings: () -> Unit,
|
||||
onAccountSwitch: (SessionId) -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
displayMenuItems: Boolean,
|
||||
|
|
@ -116,12 +129,6 @@ private fun DefaultRoomListTopBar(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val collapsedFraction = scrollBehavior.state.collapsedFraction
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
|
||||
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
|
||||
|
|
@ -158,8 +165,9 @@ private fun DefaultRoomListTopBar(
|
|||
},
|
||||
navigationIcon = {
|
||||
NavigationIcon(
|
||||
avatarData = avatarData,
|
||||
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
onAccountSwitch = onAccountSwitch,
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
},
|
||||
|
|
@ -247,19 +255,67 @@ private fun DefaultRoomListTopBar(
|
|||
|
||||
@Composable
|
||||
private fun NavigationIcon(
|
||||
avatarData: AvatarData,
|
||||
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||
showAvatarIndicator: Boolean,
|
||||
onAccountSwitch: (SessionId) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
if (currentUserAndNeighbors.size == 1) {
|
||||
AccountIcon(
|
||||
matrixUser = currentUserAndNeighbors.single(),
|
||||
isCurrentAccount = true,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
// Render a vertical pager
|
||||
val pagerState = rememberPagerState(initialPage = 1) { currentUserAndNeighbors.size }
|
||||
// Listen to page changes and switch account if needed
|
||||
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
|
||||
}
|
||||
}
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.height(48.dp),
|
||||
) { page ->
|
||||
AccountIcon(
|
||||
matrixUser = currentUserAndNeighbors[page],
|
||||
isCurrentAccount = page == 1,
|
||||
showAvatarIndicator = page == 1 && showAvatarIndicator,
|
||||
onClick = if (page == 1) {
|
||||
onClick
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountIcon(
|
||||
matrixUser: MatrixUser,
|
||||
isCurrentAccount: Boolean,
|
||||
showAvatarIndicator: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.testTag(TestTags.homeScreenSettings),
|
||||
modifier = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
contentDescription = stringResource(CommonStrings.common_settings),
|
||||
contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null,
|
||||
)
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
|
|
@ -276,11 +332,12 @@ private fun NavigationIcon(
|
|||
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
|
||||
DefaultRoomListTopBar(
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onSearchClick = {},
|
||||
displayMenuItems = true,
|
||||
displayFilters = true,
|
||||
|
|
@ -296,11 +353,33 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
|
|||
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
|
||||
DefaultRoomListTopBar(
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
|
||||
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||
showAvatarIndicator = true,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onSearchClick = {},
|
||||
displayMenuItems = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
canReportBug = true,
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun DefaultRoomListTopBarMultiAccountPreview() = ElementPreview {
|
||||
DefaultRoomListTopBar(
|
||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||
currentUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onSearchClick = {},
|
||||
displayMenuItems = true,
|
||||
displayFilters = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import org.junit.Test
|
||||
|
||||
class CurrentUserWithNeighborsBuilderTest {
|
||||
@Test
|
||||
fun `build on empty list returns current user`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser()
|
||||
val list = listOf<SessionData>()
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result).containsExactly(matrixUser)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ensure that account are sorted by position`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
position = 3,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
position = 2,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_3.value,
|
||||
position = 1,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID_3,
|
||||
A_USER_ID_2,
|
||||
A_USER_ID,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `if current user is not found, return a singleton with current user`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_3.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one account, will return a singleton`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two accounts, first is current, will return 3 items`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID_2,
|
||||
A_USER_ID,
|
||||
A_USER_ID_2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two accounts, second is current, will return 3 items`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID_2.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID,
|
||||
A_USER_ID_2,
|
||||
A_USER_ID,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three accounts, first is current, will return last current and next`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_3.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID_3,
|
||||
A_USER_ID,
|
||||
A_USER_ID_2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three accounts, second is current, will return first current and last`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID_2.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_3.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID,
|
||||
A_USER_ID_2,
|
||||
A_USER_ID_3,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three accounts, current is last, will return middle, current and first`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(id = A_USER_ID_3.value)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_2.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID_3.value,
|
||||
),
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result.map { it.userId }).containsExactly(
|
||||
A_USER_ID,
|
||||
A_USER_ID_2,
|
||||
A_USER_ID_3,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one account, will return data from matrix user and not from db`() {
|
||||
val sut = CurrentUserWithNeighborsBuilder()
|
||||
val matrixUser = aMatrixUser(
|
||||
id = A_USER_ID.value,
|
||||
displayName = "Bob",
|
||||
avatarUrl = "avatarUrl",
|
||||
)
|
||||
val list = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_USER_ID.value,
|
||||
userDisplayName = "Outdated Bob",
|
||||
userAvatarUrl = "outdatedAvatarUrl",
|
||||
),
|
||||
)
|
||||
val result = sut.build(matrixUser, list)
|
||||
assertThat(result).containsExactly(
|
||||
MatrixUser(
|
||||
userId = A_USER_ID,
|
||||
displayName = "Bob",
|
||||
avatarUrl = "avatarUrl",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.tests.testutils.MutablePresenter
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
|
|
@ -54,17 +57,29 @@ class HomePresenterTest {
|
|||
val presenter = createHomePresenter(
|
||||
client = matrixClient,
|
||||
rageshakeFeatureAvailability = { flowOf(false) },
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(
|
||||
sessionId = matrixClient.sessionId.value,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
|
||||
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
|
||||
MatrixUser(A_USER_ID, null, null)
|
||||
)
|
||||
assertThat(initialState.canReportBug).isFalse()
|
||||
skipItems(1)
|
||||
val withUserState = awaitItem()
|
||||
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
|
||||
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
|
||||
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
assertThat(withUserState.currentUserAndNeighbors.first()).isEqualTo(
|
||||
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
|
||||
)
|
||||
assertThat(withUserState.showAvatarIndicator).isFalse()
|
||||
assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
|
||||
assertThat(withUserState.showNavigationBar).isFalse()
|
||||
|
|
@ -75,6 +90,9 @@ class HomePresenterTest {
|
|||
fun `present - can report bug`() = runTest {
|
||||
val presenter = createHomePresenter(
|
||||
rageshakeFeatureAvailability = { flowOf(true) },
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -92,6 +110,9 @@ class HomePresenterTest {
|
|||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.Space.key to true),
|
||||
),
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
|
|
@ -105,6 +126,9 @@ class HomePresenterTest {
|
|||
val indicatorService = FakeIndicatorService()
|
||||
val presenter = createHomePresenter(
|
||||
indicatorService = indicatorService,
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -124,19 +148,28 @@ class HomePresenterTest {
|
|||
userAvatarUrl = null,
|
||||
)
|
||||
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
|
||||
val presenter = createHomePresenter(client = matrixClient)
|
||||
val presenter = createHomePresenter(
|
||||
client = matrixClient,
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
|
||||
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
|
||||
// No new state is coming
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - NavigationBar change`() = runTest {
|
||||
val presenter = createHomePresenter()
|
||||
val presenter = createHomePresenter(
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -152,6 +185,9 @@ class HomePresenterTest {
|
|||
fun `present - NavigationBar is hidden when the last space is left`() = runTest {
|
||||
val homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
|
||||
val presenter = createHomePresenter(
|
||||
sessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.Space.key to true),
|
||||
),
|
||||
|
|
@ -185,6 +221,7 @@ internal fun createHomePresenter(
|
|||
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = HomePresenter(
|
||||
client = client,
|
||||
syncService = syncService,
|
||||
|
|
@ -195,4 +232,5 @@ internal fun createHomePresenter(
|
|||
homeSpacesPresenter = homeSpacesPresenter,
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ dependencies {
|
|||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.qrcode)
|
||||
implementation(projects.libraries.oidc.api)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
|
|
@ -56,5 +57,6 @@ dependencies {
|
|||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oidc.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.libraries.wellknown.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class LoginFlowNode(
|
|||
// by pressing back or by closing the Custom Chrome Tab.
|
||||
lifecycleScope.launch {
|
||||
delay(5000)
|
||||
oidcActionFlow.post(OidcAction.GoBack)
|
||||
oidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,9 +94,14 @@ class LoginHelper(
|
|||
}
|
||||
|
||||
private suspend fun onOidcAction(oidcAction: OidcAction) {
|
||||
if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
|
||||
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
|
||||
// This can happen if there is an error, for instance attempt to login again on the same account.
|
||||
return
|
||||
}
|
||||
loginModeState.value = AsyncData.Loading()
|
||||
when (oidcAction) {
|
||||
OidcAction.GoBack -> {
|
||||
is OidcAction.GoBack -> {
|
||||
authenticationService.cancelOidcLogin()
|
||||
.onSuccess {
|
||||
loginModeState.value = AsyncData.Uninitialized
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
|
@ -23,6 +22,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -89,6 +89,12 @@ fun LoginModeView(
|
|||
onSubmit = onClearError,
|
||||
)
|
||||
}
|
||||
is AuthenticationException.AccountAlreadyLoggedIn -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(CommonStrings.error_account_already_logged_in, error.message.orEmpty()),
|
||||
onSubmit = onClearError,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(CommonStrings.error_unknown),
|
||||
|
|
@ -113,7 +119,7 @@ fun LoginModeView(
|
|||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
|
||||
internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
|
||||
ElementPreview {
|
||||
LoginModeView(
|
||||
loginMode = AsyncData.Failure(error),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.login
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
|
||||
class LoginModeViewErrorProvider : PreviewParameterProvider<Exception> {
|
||||
override val values: Sequence<Exception>
|
||||
get() = ChangeServerErrorProvider().values +
|
||||
AuthenticationException.AccountAlreadyLoggedIn("@alice:matrix.org")
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ class OnBoardingNode(
|
|||
onNeedLoginPassword = ::onLoginPasswordNeeded,
|
||||
onLearnMoreClick = { openLearnMorePage(context) },
|
||||
onCreateAccountContinue = ::onCreateAccountContinue,
|
||||
onBackClick = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.login.impl.login.LoginHelper
|
|||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
|
||||
|
||||
@AssistedInject
|
||||
|
|
@ -38,6 +39,7 @@ class OnBoardingPresenter(
|
|||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val loginHelper: LoginHelper,
|
||||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Presenter<OnBoardingState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -86,6 +88,10 @@ class OnBoardingPresenter(
|
|||
val onBoardingLogoResId = remember {
|
||||
onBoardingLogoResIdProvider.get()
|
||||
}
|
||||
val isAddingAccount by produceState(initialValue = false) {
|
||||
// We are adding an account if there is at least one session already stored
|
||||
value = sessionStore.getAllSessions().isNotEmpty()
|
||||
}
|
||||
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
|
||||
|
|
@ -109,6 +115,7 @@ class OnBoardingPresenter(
|
|||
}
|
||||
|
||||
return OnBoardingState(
|
||||
isAddingAccount = isAddingAccount,
|
||||
productionApplicationName = buildMeta.productionApplicationName,
|
||||
defaultAccountProvider = defaultAccountProvider,
|
||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.features.login.impl.login.LoginMode
|
|||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class OnBoardingState(
|
||||
val isAddingAccount: Boolean,
|
||||
val productionApplicationName: String,
|
||||
val defaultAccountProvider: String?,
|
||||
val mustChooseAccountProvider: Boolean,
|
||||
|
|
|
|||
|
|
@ -23,10 +23,16 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
|||
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
|
||||
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
|
||||
anOnBoardingState(customLogoResId = R.drawable.sample_background),
|
||||
anOnBoardingState(
|
||||
isAddingAccount = true,
|
||||
canLoginWithQrCode = true,
|
||||
canCreateAccount = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun anOnBoardingState(
|
||||
isAddingAccount: Boolean = false,
|
||||
productionApplicationName: String = "Element",
|
||||
defaultAccountProvider: String? = null,
|
||||
mustChooseAccountProvider: Boolean = false,
|
||||
|
|
@ -39,6 +45,7 @@ fun anOnBoardingState(
|
|||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||
) = OnBoardingState(
|
||||
isAddingAccount = isAddingAccount,
|
||||
productionApplicationName = productionApplicationName,
|
||||
defaultAccountProvider = defaultAccountProvider,
|
||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ import io.element.android.libraries.architecture.AsyncData
|
|||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
|
|
@ -58,6 +60,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun OnBoardingView(
|
||||
state: OnBoardingState,
|
||||
onBackClick: () -> Unit,
|
||||
onSignInWithQrCode: () -> Unit,
|
||||
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
|
||||
onCreateAccount: () -> Unit,
|
||||
|
|
@ -67,6 +70,52 @@ fun OnBoardingView(
|
|||
onCreateAccountContinue: (url: String) -> Unit,
|
||||
onReportProblem: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val loginView = @Composable {
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
onClearError = {
|
||||
state.eventSink(OnBoardingEvents.ClearError)
|
||||
},
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
onOidcDetails = onOidcDetails,
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onCreateAccountContinue = onCreateAccountContinue,
|
||||
)
|
||||
}
|
||||
val buttons = @Composable {
|
||||
OnBoardingButtons(
|
||||
state = state,
|
||||
onSignInWithQrCode = onSignInWithQrCode,
|
||||
onSignIn = onSignIn,
|
||||
onCreateAccount = onCreateAccount,
|
||||
onReportProblem = onReportProblem,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isAddingAccount) {
|
||||
AddOtherAccountScaffold(
|
||||
modifier = modifier,
|
||||
loginView = loginView,
|
||||
buttons = buttons,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
} else {
|
||||
AddFirstAccountScaffold(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
loginView = loginView,
|
||||
buttons = buttons,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddFirstAccountScaffold(
|
||||
state: OnBoardingState,
|
||||
loginView: @Composable () -> Unit,
|
||||
buttons: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnBoardingPage(
|
||||
modifier = modifier,
|
||||
|
|
@ -79,29 +128,31 @@ fun OnBoardingView(
|
|||
} else {
|
||||
OnBoardingContent(state = state)
|
||||
}
|
||||
LoginModeView(
|
||||
loginMode = state.loginMode,
|
||||
onClearError = {
|
||||
state.eventSink(OnBoardingEvents.ClearError)
|
||||
},
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
onOidcDetails = onOidcDetails,
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onCreateAccountContinue = onCreateAccountContinue,
|
||||
)
|
||||
loginView()
|
||||
},
|
||||
footer = {
|
||||
OnBoardingButtons(
|
||||
state = state,
|
||||
onSignInWithQrCode = onSignInWithQrCode,
|
||||
onSignIn = onSignIn,
|
||||
onCreateAccount = onCreateAccount,
|
||||
onReportProblem = onReportProblem,
|
||||
)
|
||||
buttons()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddOtherAccountScaffold(
|
||||
loginView: @Composable () -> Unit,
|
||||
buttons: @Composable () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
title = stringResource(CommonStrings.common_add_account),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
|
||||
buttons = { buttons() },
|
||||
content = loginView,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnBoardingContent(state: OnBoardingState) {
|
||||
Box(
|
||||
|
|
@ -226,27 +277,29 @@ private fun OnBoardingButtons(
|
|||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
if (state.canReportBug) {
|
||||
// Add a report problem text button. Use a Text since we need a special theme here.
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onReportProblem)
|
||||
.padding(16.dp),
|
||||
text = stringResource(id = CommonStrings.common_report_a_problem),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
state.eventSink(OnBoardingEvents.OnVersionClick)
|
||||
}
|
||||
.padding(16.dp),
|
||||
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
if (state.isAddingAccount.not()) {
|
||||
if (state.canReportBug) {
|
||||
// Add a report problem text button. Use a Text since we need a special theme here.
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onReportProblem)
|
||||
.padding(16.dp),
|
||||
text = stringResource(id = CommonStrings.common_report_a_problem),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
state.eventSink(OnBoardingEvents.OnVersionClick)
|
||||
}
|
||||
.padding(16.dp),
|
||||
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +311,7 @@ internal fun OnBoardingViewPreview(
|
|||
) = ElementPreview {
|
||||
OnBoardingView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onSignInWithQrCode = {},
|
||||
onSignIn = {},
|
||||
onCreateAccount = {},
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||
authenticationService.givenOidcCancelError(AN_EXCEPTION)
|
||||
defaultOidcActionFlow.post(OidcAction.GoBack)
|
||||
defaultOidcActionFlow.post(OidcAction.GoBack())
|
||||
val cancelFailureState = awaitItem()
|
||||
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||
}
|
||||
|
|
@ -144,7 +144,30 @@ class ConfirmAccountProviderPresenterTest {
|
|||
assertThat(successState.submitEnabled).isFalse()
|
||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||
defaultOidcActionFlow.post(OidcAction.GoBack)
|
||||
defaultOidcActionFlow.post(OidcAction.GoBack())
|
||||
val cancelFinalState = awaitItem()
|
||||
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - oidc - cancel to unblock`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService()
|
||||
val defaultOidcActionFlow = FakeOidcActionFlow()
|
||||
val presenter = createConfirmAccountProviderPresenter(
|
||||
matrixAuthenticationService = authenticationService,
|
||||
defaultOidcActionFlow = defaultOidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitEnabled).isTrue()
|
||||
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||
defaultOidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
|
||||
val cancelFinalState = awaitItem()
|
||||
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
|
|||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
|
|
@ -79,10 +82,27 @@ class OnBoardingPresenterTest {
|
|||
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
||||
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
||||
assertThat(initialState.canReportBug).isFalse()
|
||||
assertThat(initialState.isAddingAccount).isFalse()
|
||||
assertThat(awaitItem().canLoginWithQrCode).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state adding account`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData()
|
||||
)
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isAddingAccount).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - on boarding logo`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
|
|
@ -236,6 +256,7 @@ private fun createPresenter(
|
|||
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
|
||||
loginHelper: LoginHelper = createLoginHelper(),
|
||||
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = OnBoardingPresenter(
|
||||
params = params,
|
||||
buildMeta = buildMeta,
|
||||
|
|
@ -247,6 +268,7 @@ private fun createPresenter(
|
|||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
loginHelper = loginHelper,
|
||||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
|
||||
fun createLoginHelper(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ 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
|
||||
|
|
@ -50,6 +51,21 @@ class OnboardingViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when can go back - clicking on back calls the expected callback`() {
|
||||
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setOnboardingView(
|
||||
state = anOnBoardingState(
|
||||
isAddingAccount = true,
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onBackClick = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
|
||||
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||
|
|
@ -235,6 +251,7 @@ class OnboardingViewTest {
|
|||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
||||
state: OnBoardingState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
||||
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onCreateAccount: () -> Unit = EnsureNeverCalled(),
|
||||
|
|
@ -247,6 +264,7 @@ class OnboardingViewTest {
|
|||
setContent {
|
||||
OnBoardingView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onSignInWithQrCode = onSignInWithQrCode,
|
||||
onSignIn = onSignIn,
|
||||
onCreateAccount = onCreateAccount,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
|||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
|
|
@ -41,9 +40,10 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
|||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onAddAccount()
|
||||
fun onOpenBugReport()
|
||||
fun onSecureBackupClick()
|
||||
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
||||
fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId)
|
||||
fun navigateTo(roomId: RoomId, eventId: EventId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ dependencies {
|
|||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.libraries.indicator.test)
|
||||
testImplementation(projects.libraries.pushproviders.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import io.element.android.libraries.architecture.createNode
|
|||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||
|
|
@ -117,6 +116,10 @@ class PreferencesFlowNode(
|
|||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : PreferencesRootNode.Callback {
|
||||
override fun onAddAccount() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onAddAccount() }
|
||||
}
|
||||
|
||||
override fun onOpenBugReport() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
|
@ -226,8 +229,8 @@ class PreferencesFlowNode(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.navigateTo(sessionId, roomId, eventId) }
|
||||
override fun navigateTo(roomId: RoomId, eventId: EventId) {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.navigateTo(roomId, eventId) }
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface PreferencesRootEvents {
|
||||
data object OnVersionInfoClick : PreferencesRootEvents
|
||||
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class PreferencesRootNode(
|
|||
private val directLogoutView: DirectLogoutView,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onAddAccount()
|
||||
fun onOpenBugReport()
|
||||
fun onSecureBackupClick()
|
||||
fun onOpenAnalytics()
|
||||
|
|
@ -48,6 +49,10 @@ class PreferencesRootNode(
|
|||
fun onOpenAccountDeactivation()
|
||||
}
|
||||
|
||||
private fun onAddAccount() {
|
||||
plugins<Callback>().forEach { it.onAddAccount() }
|
||||
}
|
||||
|
||||
private fun onOpenBugReport() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
|
@ -119,6 +124,7 @@ class PreferencesRootNode(
|
|||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = this::navigateUp,
|
||||
onAddAccountClick = this::onAddAccount,
|
||||
onOpenRageShake = this::onOpenBugReport,
|
||||
onOpenAnalytics = this::onOpenAnalytics,
|
||||
onOpenAbout = this::onOpenAbout,
|
||||
|
|
|
|||
|
|
@ -24,13 +24,21 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -45,6 +53,8 @@ class PreferencesRootPresenter(
|
|||
private val directLogoutPresenter: Presenter<DirectLogoutState>,
|
||||
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
|
||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
@Composable
|
||||
override fun present(): PreferencesRootState {
|
||||
|
|
@ -55,6 +65,25 @@ class PreferencesRootPresenter(
|
|||
matrixClient.getUserProfile()
|
||||
}
|
||||
|
||||
val isMultiAccountEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
val otherSessions by remember {
|
||||
sessionStore.sessionsFlow().map { list ->
|
||||
list
|
||||
.filter { it.userId != matrixClient.sessionId.value }
|
||||
.map {
|
||||
MatrixUser(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
)
|
||||
}
|
||||
.toPersistentList()
|
||||
}
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
|
||||
|
||||
|
|
@ -96,6 +125,9 @@ class PreferencesRootPresenter(
|
|||
is PreferencesRootEvents.OnVersionInfoClick -> {
|
||||
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
|
||||
}
|
||||
is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch {
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +135,8 @@ class PreferencesRootPresenter(
|
|||
myUser = matrixUser.value,
|
||||
version = versionFormatter.get(),
|
||||
deviceId = matrixClient.deviceId,
|
||||
isMultiAccountEnabled = isMultiAccountEnabled,
|
||||
otherSessions = otherSessions,
|
||||
showSecureBackup = !canVerifyUserSession,
|
||||
showSecureBackupBadge = showSecureBackupIndicator,
|
||||
accountManagementUrl = accountManagementUrl.value,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
|
|||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class PreferencesRootState(
|
||||
val myUser: MatrixUser,
|
||||
val version: String,
|
||||
val deviceId: DeviceId?,
|
||||
val isMultiAccountEnabled: Boolean,
|
||||
val otherSessions: ImmutableList<MatrixUser>,
|
||||
val showSecureBackup: Boolean,
|
||||
val showSecureBackupBadge: Boolean,
|
||||
val accountManagementUrl: String?,
|
||||
|
|
|
|||
|
|
@ -11,15 +11,20 @@ import io.element.android.features.logout.api.direct.aDirectLogoutState
|
|||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
fun aPreferencesRootState(
|
||||
myUser: MatrixUser,
|
||||
myUser: MatrixUser = aMatrixUser(),
|
||||
otherSessions: List<MatrixUser> = emptyList(),
|
||||
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
|
||||
) = PreferencesRootState(
|
||||
myUser = myUser,
|
||||
version = "Version 1.1 (1)",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
isMultiAccountEnabled = true,
|
||||
otherSessions = otherSessions.toPersistentList(),
|
||||
showSecureBackup = true,
|
||||
showSecureBackupBadge = true,
|
||||
accountManagementUrl = "aUrl",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -23,11 +24,14 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
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.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
|
|
@ -38,12 +42,15 @@ import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbar
|
|||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PreferencesRootView(
|
||||
state: PreferencesRootState,
|
||||
onBackClick: () -> Unit,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onSecureBackupClick: () -> Unit,
|
||||
onManageAccountClick: (url: String) -> Unit,
|
||||
onOpenAnalytics: () -> Unit,
|
||||
|
|
@ -74,7 +81,12 @@ fun PreferencesRootView(
|
|||
},
|
||||
user = state.myUser,
|
||||
)
|
||||
|
||||
if (state.isMultiAccountEnabled) {
|
||||
MultiAccountSection(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
)
|
||||
}
|
||||
// 'Manage my app' section
|
||||
ManageAppSection(
|
||||
state = state,
|
||||
|
|
@ -114,6 +126,38 @@ fun PreferencesRootView(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MultiAccountSection(
|
||||
state: PreferencesRootState,
|
||||
onAddAccountClick: () -> Unit,
|
||||
) {
|
||||
HorizontalDivider(
|
||||
thickness = 8.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
state.otherSessions.forEach { matrixUser ->
|
||||
MatrixUserRow(
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId))
|
||||
},
|
||||
matrixUser = matrixUser,
|
||||
avatarSize = AvatarSize.AccountItem,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
ListItem(
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
|
||||
headlineContent = {
|
||||
Text(stringResource(CommonStrings.common_add_another_account))
|
||||
},
|
||||
onClick = onAddAccountClick,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 8.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ManageAppSection(
|
||||
state: PreferencesRootState,
|
||||
|
|
@ -287,6 +331,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
|||
PreferencesRootView(
|
||||
state = aPreferencesRootState(myUser = matrixUser),
|
||||
onBackClick = {},
|
||||
onAddAccountClick = {},
|
||||
onOpenAnalytics = {},
|
||||
onOpenRageShake = {},
|
||||
onOpenDeveloperSettings = {},
|
||||
|
|
@ -302,3 +347,16 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
|||
onDeactivateClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MultiAccountSectionPreview() = ElementPreview {
|
||||
Column {
|
||||
MultiAccountSection(
|
||||
state = aPreferencesRootState(
|
||||
otherSessions = aMatrixUserList(),
|
||||
),
|
||||
onAddAccountClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import io.element.android.features.logout.api.LogoutEntryPoint
|
|||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
|
@ -64,10 +63,11 @@ class DefaultPreferencesEntryPointTest {
|
|||
)
|
||||
}
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
override fun onAddAccount() = lambdaError()
|
||||
override fun onOpenBugReport() = lambdaError()
|
||||
override fun onSecureBackupClick() = lambdaError()
|
||||
override fun onOpenRoomNotificationSettings(roomId: RoomId) = lambdaError()
|
||||
override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError()
|
||||
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
|
||||
}
|
||||
val params = PreferencesEntryPoint.Params(
|
||||
initialElement = PreferencesEntryPoint.InitialTarget.NotificationSettings,
|
||||
|
|
|
|||
|
|
@ -16,15 +16,23 @@ import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsP
|
|||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
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.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -61,6 +69,8 @@ class PreferencesRootPresenterTest {
|
|||
)
|
||||
)
|
||||
assertThat(initialState.version).isEqualTo("A Version")
|
||||
assertThat(initialState.isMultiAccountEnabled).isFalse()
|
||||
assertThat(initialState.otherSessions).isEmpty()
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.myUser).isEqualTo(
|
||||
MatrixUser(
|
||||
|
|
@ -174,6 +184,34 @@ class PreferencesRootPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - multiple accounts`() = runTest {
|
||||
createPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
canDeactivateAccountResult = { true },
|
||||
),
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.MultiAccount.key to true)
|
||||
),
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = A_SESSION_ID.value),
|
||||
aSessionData(
|
||||
sessionId = A_SESSION_ID_2.value,
|
||||
userDisplayName = "Bob",
|
||||
userAvatarUrl = "avatarUrl",
|
||||
),
|
||||
)
|
||||
)
|
||||
).test {
|
||||
val state = awaitFirstItem()
|
||||
assertThat(state.isMultiAccountEnabled).isTrue()
|
||||
assertThat(state.otherSessions).hasSize(1)
|
||||
assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
|
|
@ -185,6 +223,8 @@ class PreferencesRootPresenterTest {
|
|||
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
|
||||
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) },
|
||||
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = PreferencesRootPresenter(
|
||||
matrixClient = matrixClient,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
|
|
@ -195,5 +235,7 @@ class PreferencesRootPresenterTest {
|
|||
directLogoutPresenter = { aDirectLogoutState() },
|
||||
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,5 +43,9 @@ private fun aSessionData(
|
|||
passphrase = null,
|
||||
sessionPath = "/a/path/to/a/session",
|
||||
cachePath = "/a/path/to/a/cache",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue