Merge branch 'develop' into feature/fga/pinned_messages_list_remove_reaction
This commit is contained in:
@@ -19,7 +19,6 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.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.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
@@ -31,10 +30,8 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
|
||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
||||
import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
@@ -112,9 +109,6 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
@Parcelize
|
||||
data object LoginPassword : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class WaitList(val loginFormState: LoginFormState) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||
}
|
||||
@@ -181,27 +175,11 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.LoginPassword -> {
|
||||
val callback = object : LoginPasswordNode.Callback {
|
||||
override fun onWaitListError(loginFormState: LoginFormState) {
|
||||
backstack.newRoot(NavTarget.WaitList(loginFormState))
|
||||
}
|
||||
}
|
||||
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
|
||||
createNode<LoginPasswordNode>(buildContext)
|
||||
}
|
||||
is NavTarget.OidcView -> {
|
||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
||||
}
|
||||
is NavTarget.WaitList -> {
|
||||
val inputs = WaitListNode.Inputs(
|
||||
loginFormState = navTarget.loginFormState,
|
||||
)
|
||||
val callback = object : WaitListNode.Callback {
|
||||
override fun onCancelClick() {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.error
|
||||
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
|
||||
fun Throwable.isWaitListError(): Boolean {
|
||||
return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse()
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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 com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
@@ -24,14 +23,6 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LoginPasswordPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onWaitListError(loginFormState: LoginFormState)
|
||||
}
|
||||
|
||||
private fun onWaitListError(loginFormState: LoginFormState) {
|
||||
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
@@ -39,7 +30,6 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = ::navigateUp,
|
||||
onWaitListError = ::onWaitListError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ 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.login.impl.R
|
||||
import io.element.android.features.login.impl.error.isWaitListError
|
||||
import io.element.android.features.login.impl.error.loginError
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
@@ -72,7 +71,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun LoginPasswordView(
|
||||
state: LoginPasswordState,
|
||||
onBackClick: () -> Unit,
|
||||
onWaitListError: (LoginFormState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isLoading by remember(state.loginAction) {
|
||||
@@ -149,16 +147,9 @@ fun LoginPasswordView(
|
||||
}
|
||||
|
||||
if (state.loginAction is AsyncData.Failure) {
|
||||
when {
|
||||
state.loginAction.error.isWaitListError() -> {
|
||||
onWaitListError(state.formState)
|
||||
}
|
||||
else -> {
|
||||
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
||||
state.eventSink(LoginPasswordEvents.ClearError)
|
||||
})
|
||||
}
|
||||
}
|
||||
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
||||
state.eventSink(LoginPasswordEvents.ClearError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +293,5 @@ internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvid
|
||||
LoginPasswordView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onWaitListError = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
sealed interface WaitListEvents {
|
||||
data object AttemptLogin : WaitListEvents
|
||||
data object ClearError : WaitListEvents
|
||||
data object Continue : WaitListEvents
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
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 com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class WaitListNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: WaitListPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(val loginFormState: LoginFormState) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
private val presenter = presenterFactory.create(inputs.loginFormState)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onCancelClick()
|
||||
}
|
||||
|
||||
private fun onCancelClick() {
|
||||
plugins<Callback>().forEach { it.onCancelClick() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
WaitListView(
|
||||
state = state,
|
||||
onCancelClick = ::onCancelClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class WaitListPresenter @AssistedInject constructor(
|
||||
@Assisted private val formState: LoginFormState,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||
) : Presenter<WaitListState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(loginFormState: LoginFormState): WaitListPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): WaitListState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val homeserverUrl = remember {
|
||||
authenticationService.getHomeserverDetails().value?.url ?: "server"
|
||||
}
|
||||
|
||||
val loginAction: MutableState<AsyncData<SessionId>> = remember {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
val attemptNumber = remember { mutableIntStateOf(0) }
|
||||
|
||||
fun handleEvents(event: WaitListEvents) {
|
||||
when (event) {
|
||||
WaitListEvents.AttemptLogin -> {
|
||||
// Do not attempt to login on first resume of the View.
|
||||
attemptNumber.intValue++
|
||||
if (attemptNumber.intValue > 1) {
|
||||
coroutineScope.loginAttempt(formState, loginAction)
|
||||
}
|
||||
}
|
||||
WaitListEvents.ClearError -> loginAction.value = AsyncData.Uninitialized
|
||||
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true)
|
||||
}
|
||||
}
|
||||
|
||||
return WaitListState(
|
||||
appName = buildMeta.applicationName,
|
||||
serverName = homeserverUrl,
|
||||
loginAction = loginAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<AsyncData<SessionId>>) = launch {
|
||||
Timber.w("Attempt to login...")
|
||||
loggedInState.value = AsyncData.Loading()
|
||||
authenticationService.login(formState.login.trim(), formState.password)
|
||||
.onSuccess { sessionId ->
|
||||
loggedInState.value = AsyncData.Success(sessionId)
|
||||
}
|
||||
.onFailure { failure ->
|
||||
loggedInState.value = AsyncData.Failure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
// Do not use default value, so no member get forgotten in the presenters.
|
||||
data class WaitListState(
|
||||
val appName: String,
|
||||
val serverName: String,
|
||||
val loginAction: AsyncData<SessionId>,
|
||||
val eventSink: (WaitListEvents) -> Unit
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> {
|
||||
override val values: Sequence<WaitListState>
|
||||
get() = sequenceOf(
|
||||
aWaitListState(loginAction = AsyncData.Uninitialized),
|
||||
aWaitListState(loginAction = AsyncData.Loading()),
|
||||
aWaitListState(loginAction = AsyncData.Failure(Throwable("error"))),
|
||||
aWaitListState(loginAction = AsyncData.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))),
|
||||
aWaitListState(loginAction = AsyncData.Success(SessionId("@alice:element.io"))),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
fun aWaitListState(
|
||||
appName: String = "Element X",
|
||||
serverName: String = "server.org",
|
||||
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized,
|
||||
) = WaitListState(
|
||||
appName = appName,
|
||||
serverName = serverName,
|
||||
loginAction = loginAction,
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.error.isWaitListError
|
||||
import io.element.android.features.login.impl.error.loginError
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
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
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
|
||||
// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode.
|
||||
@Composable
|
||||
fun WaitListView(
|
||||
state: WaitListState,
|
||||
onCancelClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
WaitListContent(state, onCancelClick, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitListError(state: WaitListState) {
|
||||
// Display a dialog for error other than the waitlist error
|
||||
state.loginAction.errorOrNull()?.let { error ->
|
||||
if (error.isWaitListError().not()) {
|
||||
RetryDialog(
|
||||
content = stringResource(id = loginError(error)),
|
||||
onRetry = {
|
||||
state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink.invoke(WaitListEvents.ClearError)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitListContent(
|
||||
state: WaitListState,
|
||||
onCancelClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
val title = stringResource(
|
||||
when (state.loginAction) {
|
||||
is AsyncData.Success -> R.string.screen_waitlist_title_success
|
||||
else -> R.string.screen_waitlist_title
|
||||
}
|
||||
)
|
||||
val subtitle = when (state.loginAction) {
|
||||
is AsyncData.Success -> stringResource(
|
||||
id = R.string.screen_waitlist_message_success,
|
||||
state.appName,
|
||||
)
|
||||
else -> stringResource(
|
||||
id = R.string.screen_waitlist_message,
|
||||
state.appName,
|
||||
state.serverName,
|
||||
)
|
||||
}
|
||||
SunsetPage(
|
||||
isLoading = state.loginAction.isLoading(),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
) {
|
||||
OverallContent(state, onCancelClick)
|
||||
}
|
||||
WaitListError(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverallContent(
|
||||
state: WaitListState,
|
||||
onCancelClick: () -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.loginAction !is AsyncData.Success) {
|
||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = onCancelClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loginAction is AsyncData.Success) {
|
||||
Button(
|
||||
text = stringResource(id = CommonStrings.action_continue),
|
||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview {
|
||||
WaitListView(
|
||||
state = state,
|
||||
onCancelClick = {},
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class WaitListPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService().apply {
|
||||
givenHomeserver(A_HOMESERVER)
|
||||
}
|
||||
val loginUserStory = DefaultLoginUserStory()
|
||||
val presenter = WaitListPresenter(
|
||||
LoginFormState.Default,
|
||||
aBuildMeta(applicationName = "Application Name"),
|
||||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.appName).isEqualTo("Application Name")
|
||||
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL)
|
||||
assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - attempt login with error`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService().apply {
|
||||
givenLoginError(A_THROWABLE)
|
||||
}
|
||||
val loginUserStory = DefaultLoginUserStory()
|
||||
val presenter = WaitListPresenter(
|
||||
LoginFormState.Default,
|
||||
aBuildMeta(),
|
||||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
// First usage of AttemptLogin, nothing should happen
|
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
expectNoEvents()
|
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
val submitState = awaitItem()
|
||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE))
|
||||
// Assert the error can be cleared
|
||||
errorState.eventSink(WaitListEvents.ClearError)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.loginAction).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - attempt login with success`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService()
|
||||
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
|
||||
val presenter = WaitListPresenter(
|
||||
LoginFormState.Default,
|
||||
aBuildMeta(),
|
||||
authenticationService,
|
||||
loginUserStory,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||
val initialState = awaitItem()
|
||||
// First usage of AttemptLogin, nothing should happen
|
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
expectNoEvents()
|
||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||
val submitState = awaitItem()
|
||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.loginAction).isEqualTo(AsyncData.Success(A_USER_ID))
|
||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||
successState.eventSink.invoke(WaitListEvents.Continue)
|
||||
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface LogoutEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun onSuccessfulLogoutPendingAction(action: () -> Unit): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@ class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun onSuccessfulLogoutPendingAction(action: () -> Unit): LogoutEntryPoint.NodeBuilder {
|
||||
plugins += object : LogoutNode.SuccessfulLogoutPendingAction, Plugin {
|
||||
override fun onSuccessfulLogoutPendingAction() {
|
||||
action()
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<LogoutNode>(buildContext, plugins)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ class LogoutNode @AssistedInject constructor(
|
||||
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClick() }
|
||||
}
|
||||
|
||||
interface SuccessfulLogoutPendingAction : Plugin {
|
||||
fun onSuccessfulLogoutPendingAction()
|
||||
}
|
||||
|
||||
private val customOnSuccessfulLogoutPendingAction = plugins<SuccessfulLogoutPendingAction>().firstOrNull()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
@@ -41,7 +47,10 @@ class LogoutNode @AssistedInject constructor(
|
||||
LogoutView(
|
||||
state = state,
|
||||
onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick,
|
||||
onSuccessLogout = { onSuccessLogout(activity, isDark, it) },
|
||||
onSuccessLogout = {
|
||||
customOnSuccessfulLogoutPendingAction?.onSuccessfulLogoutPendingAction()
|
||||
onSuccessLogout(activity, isDark, it)
|
||||
},
|
||||
onBackClick = ::navigateUp,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -32,7 +34,8 @@ class PinnedEventsTimelineProvider @Inject constructor(
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : TimelineProvider {
|
||||
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
|
||||
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
|
||||
MutableStateFlow(AsyncData.Uninitialized)
|
||||
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
||||
return _timelineStateFlow
|
||||
@@ -44,25 +47,46 @@ class PinnedEventsTimelineProvider @Inject constructor(
|
||||
val timelineStateFlow = _timelineStateFlow
|
||||
|
||||
fun launchIn(scope: CoroutineScope) {
|
||||
_timelineStateFlow.subscriptionCount
|
||||
.map { count -> count > 0 }
|
||||
.distinctUntilChanged()
|
||||
.onEach { isActive ->
|
||||
if (isActive) {
|
||||
onActive()
|
||||
} else {
|
||||
onInactive()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
private suspend fun onActive() = coroutineScope {
|
||||
combine(
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
|
||||
networkMonitor.connectivity
|
||||
) {
|
||||
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
||||
isEnabled, _ ->
|
||||
) { isEnabled, _ ->
|
||||
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
||||
isEnabled
|
||||
}
|
||||
.onEach { isFeatureEnabled ->
|
||||
if (isFeatureEnabled) {
|
||||
loadTimelineIfNeeded()
|
||||
} else {
|
||||
_timelineStateFlow.value = AsyncData.Uninitialized
|
||||
resetTimeline()
|
||||
}
|
||||
}
|
||||
.onCompletion {
|
||||
invokeOnTimeline { close() }
|
||||
}
|
||||
.launchIn(scope)
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private suspend fun onInactive() {
|
||||
resetTimeline()
|
||||
}
|
||||
|
||||
private suspend fun resetTimeline() {
|
||||
invokeOnTimeline {
|
||||
close()
|
||||
}
|
||||
_timelineStateFlow.emit(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
@@ -60,6 +59,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
private val timelineProvider: PinnedEventsTimelineProvider,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
) : Presenter<PinnedMessagesListState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -100,10 +100,9 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
}
|
||||
)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun handleEvents(event: PinnedMessagesListEvents) {
|
||||
when (event) {
|
||||
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
|
||||
is PinnedMessagesListEvents.HandleAction -> appCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,12 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
@@ -40,7 +38,6 @@ fun TimelineItemVirtualRow(
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
|
||||
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
|
||||
is TimelineItemLoadingIndicatorModel -> {
|
||||
TimelineLoadingMoreIndicator(virtual.model.direction)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
||||
@Composable
|
||||
fun TimelineEncryptedHistoryBannerView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
|
||||
.background(ElementTheme.colors.bgInfoSubtle)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.InfoSolid(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconInfoPrimary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textInfoPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
package io.element.android.features.messages.impl.timeline.factories.virtual
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
@@ -34,7 +33,6 @@ class TimelineItemVirtualFactory @Inject constructor(
|
||||
return when (val inner = virtual) {
|
||||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
||||
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
||||
direction = inner.direction,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
||||
|
||||
data object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
|
||||
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
|
||||
}
|
||||
@@ -35,11 +35,14 @@ import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PinnedMessagesListPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state feature disabled`() = runTest {
|
||||
@@ -155,6 +158,7 @@ class PinnedMessagesListPresenterTest {
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(redactEventLambda)
|
||||
.isCalledOnce()
|
||||
@@ -184,9 +188,11 @@ class PinnedMessagesListPresenterTest {
|
||||
|
||||
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||
advanceUntilIdle()
|
||||
|
||||
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||
advanceUntilIdle()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
||||
@@ -221,6 +227,7 @@ class PinnedMessagesListPresenterTest {
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onViewInTimelineClickLambda)
|
||||
.isCalledOnce()
|
||||
@@ -249,6 +256,7 @@ class PinnedMessagesListPresenterTest {
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onShowEventDebugInfoClickLambda)
|
||||
.isCalledOnce()
|
||||
@@ -277,6 +285,7 @@ class PinnedMessagesListPresenterTest {
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
|
||||
advanceUntilIdle()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onForwardEventClickLambda)
|
||||
.isCalledOnce()
|
||||
@@ -322,6 +331,7 @@ class PinnedMessagesListPresenterTest {
|
||||
timelineProvider = timelineProvider,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
||||
appCoroutineScope = this,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
||||
fun onRoomSettingsClick(roomId: RoomId)
|
||||
fun onReportBugClick()
|
||||
fun onRoomDirectorySearchClick()
|
||||
fun onLogoutForNativeSlidingSyncMigrationNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ dependencies {
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.features.invite.api)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.features.leaveroom.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@@ -75,6 +76,7 @@ dependencies {
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.features.leaveroom.test)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
|
||||
aRoomsContentState(summaries = persistentListOf()),
|
||||
aSkeletonContentState(),
|
||||
anEmptyContentState(),
|
||||
aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
sealed interface RoomListEvents {
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||
data object DismissRecoveryKeyPrompt : RoomListEvents
|
||||
data object DismissBanner : RoomListEvents
|
||||
data object ToggleSearchResults : RoomListEvents
|
||||
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||
|
||||
@@ -21,11 +21,14 @@ import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteView
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutView
|
||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -36,6 +39,8 @@ class RoomListNode @AssistedInject constructor(
|
||||
private val inviteFriendsUseCase: InviteFriendsUseCase,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val acceptDeclineInviteView: AcceptDeclineInviteView,
|
||||
private val directLogoutView: DirectLogoutView,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
@@ -88,6 +93,7 @@ class RoomListNode @AssistedInject constructor(
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val activity = LocalContext.current as Activity
|
||||
|
||||
RoomListView(
|
||||
state = state,
|
||||
onRoomClick = this::onRoomClick,
|
||||
@@ -98,6 +104,13 @@ class RoomListNode @AssistedInject constructor(
|
||||
onRoomSettingsClick = this::onRoomSettingsClick,
|
||||
onMenuActionClick = { onMenuActionClick(activity, it) },
|
||||
onRoomDirectorySearchClick = this::onRoomDirectorySearchClick,
|
||||
onMigrateToNativeSlidingSyncClick = {
|
||||
if (state.directLogoutState.canDoDirectSignOut) {
|
||||
state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))
|
||||
} else {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onLogoutForNativeSlidingSyncMigrationNeeded() }
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
acceptDeclineInviteView.Render(
|
||||
@@ -107,5 +120,9 @@ class RoomListNode @AssistedInject constructor(
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
directLogoutView.Render(state.directLogoutState) {
|
||||
enableNativeSlidingSyncUseCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
@@ -88,6 +89,7 @@ class RoomListPresenter @Inject constructor(
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
private val fullScreenIntentPermissionsPresenter: FullScreenIntentPermissionsPresenter,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val logoutPresenter: DirectLogoutPresenter,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService: EncryptionService = client.encryptionService()
|
||||
private val syncService: SyncService = client.syncService()
|
||||
@@ -115,13 +117,15 @@ class RoomListPresenter @Inject constructor(
|
||||
|
||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||
|
||||
val directLogoutState = logoutPresenter.present()
|
||||
|
||||
fun handleEvents(event: RoomListEvents) {
|
||||
when (event) {
|
||||
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
|
||||
updateVisibleRange(event.range)
|
||||
}
|
||||
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
|
||||
RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true
|
||||
RoomListEvents.DismissBanner -> securityBannerDismissed = true
|
||||
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||
is RoomListEvents.ShowContextMenu -> {
|
||||
coroutineScope.showContextMenu(event, contextMenu)
|
||||
@@ -161,6 +165,7 @@ class RoomListPresenter @Inject constructor(
|
||||
searchState = searchState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
directLogoutState = directLogoutState,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -168,6 +173,7 @@ class RoomListPresenter @Inject constructor(
|
||||
@Composable
|
||||
private fun securityBannerState(
|
||||
securityBannerDismissed: Boolean,
|
||||
needsSlidingSyncMigration: Boolean,
|
||||
): State<SecurityBannerState> {
|
||||
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
@@ -185,6 +191,7 @@ class RoomListPresenter @Inject constructor(
|
||||
RecoveryState.ENABLED -> SecurityBannerState.None
|
||||
}
|
||||
}
|
||||
needsSlidingSyncMigration -> SecurityBannerState.NeedsNativeSlidingSyncMigration
|
||||
else -> SecurityBannerState.None
|
||||
}
|
||||
}
|
||||
@@ -209,11 +216,14 @@ class RoomListPresenter @Inject constructor(
|
||||
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
|
||||
}
|
||||
}
|
||||
val needsSlidingSyncMigration by produceState(false) {
|
||||
value = client.isNativeSlidingSyncSupported() && !client.isUsingNativeSlidingSync()
|
||||
}
|
||||
return when {
|
||||
showEmpty -> RoomListContentState.Empty
|
||||
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
||||
else -> {
|
||||
val securityBannerState by securityBannerState(securityBannerDismissed)
|
||||
val securityBannerState by securityBannerState(securityBannerDismissed, needsSlidingSyncMigration)
|
||||
RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
|
||||
@@ -10,6 +10,7 @@ package io.element.android.features.roomlist.impl
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
@@ -31,6 +32,7 @@ data class RoomListState(
|
||||
val searchState: RoomListSearchState,
|
||||
val contentState: RoomListContentState,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val directLogoutState: DirectLogoutState,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
val displayFilters = contentState is RoomListContentState.Rooms
|
||||
@@ -59,6 +61,7 @@ enum class SecurityBannerState {
|
||||
None,
|
||||
SetUpRecovery,
|
||||
RecoveryKeyConfirmation,
|
||||
NeedsNativeSlidingSyncMigration,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -12,6 +12,8 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
@@ -57,6 +59,7 @@ internal fun aRoomListState(
|
||||
filtersState: RoomListFiltersState = aRoomListFiltersState(),
|
||||
contentState: RoomListContentState = aRoomsContentState(),
|
||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||
eventSink: (RoomListEvents) -> Unit = {}
|
||||
) = RoomListState(
|
||||
matrixUser = matrixUser,
|
||||
@@ -69,6 +72,7 @@ internal fun aRoomListState(
|
||||
searchState = searchState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
directLogoutState = directLogoutState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ fun RoomListView(
|
||||
onRoomSettingsClick: (roomId: RoomId) -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
onRoomDirectorySearchClick: () -> Unit,
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
acceptDeclineInviteView: @Composable () -> Unit,
|
||||
) {
|
||||
@@ -76,6 +77,7 @@ fun RoomListView(
|
||||
onOpenSettings = onSettingsClick,
|
||||
onCreateRoomClick = onCreateRoomClick,
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
@@ -105,6 +107,7 @@ private fun RoomListScaffold(
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateRoomClick: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onRoomClick(room: RoomListRoomSummary) {
|
||||
@@ -140,6 +143,7 @@ private fun RoomListScaffold(
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = ::onRoomClick,
|
||||
onCreateRoomClick = onCreateRoomClick,
|
||||
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
@@ -180,5 +184,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
|
||||
onMenuActionClick = {},
|
||||
onRoomDirectorySearchClick = {},
|
||||
acceptDeclineInviteView = {},
|
||||
onMigrateToNativeSlidingSyncClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.roomlist.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
@Composable
|
||||
internal fun NativeSlidingSyncMigrationBanner(
|
||||
onContinueClick: () -> Unit,
|
||||
onDismissClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
DialogLikeBannerMolecule(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.banner_migrate_to_native_sliding_sync_title),
|
||||
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_description),
|
||||
actionText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
|
||||
onSubmitClick = onContinueClick,
|
||||
onDismissClick = onDismissClick,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun NativeSlidingSyncMigrationBannerPreview() = ElementPreview {
|
||||
NativeSlidingSyncMigrationBanner(
|
||||
onContinueClick = {},
|
||||
onDismissClick = {},
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,7 @@ fun RoomListContentView(
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
onCreateRoomClick: () -> Unit,
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -85,6 +86,7 @@ fun RoomListContentView(
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||
onRoomClick = onRoomClick,
|
||||
)
|
||||
}
|
||||
@@ -133,6 +135,7 @@ private fun RoomsView(
|
||||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
||||
@@ -147,6 +150,7 @@ private fun RoomsView(
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = onRoomClick,
|
||||
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -159,6 +163,7 @@ private fun RoomsViewList(
|
||||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -185,7 +190,7 @@ private fun RoomsViewList(
|
||||
item {
|
||||
SetUpRecoveryKeyBanner(
|
||||
onContinueClick = onSetUpRecoveryClick,
|
||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
|
||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -193,7 +198,15 @@ private fun RoomsViewList(
|
||||
item {
|
||||
ConfirmRecoveryKeyBanner(
|
||||
onContinueClick = onConfirmRecoveryKeyClick,
|
||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
|
||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SecurityBannerState.NeedsNativeSlidingSyncMigration -> {
|
||||
item {
|
||||
NativeSlidingSyncMigrationBanner(
|
||||
onContinueClick = onMigrateToNativeSlidingSyncClick,
|
||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,5 +291,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
onRoomClick = {},
|
||||
onCreateRoomClick = {},
|
||||
onMigrateToNativeSlidingSyncClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out & Upgrade"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
|
||||
<string name="banner_set_up_recovery_content">"Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."</string>
|
||||
<string name="banner_set_up_recovery_title">"Set up recovery"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
@@ -18,6 +19,8 @@ import io.element.android.features.invite.api.response.anAcceptDeclineInviteStat
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
@@ -240,7 +243,7 @@ class RoomListPresenterTest {
|
||||
sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenNeedsSessionVerification(false)
|
||||
},
|
||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
|
||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
|
||||
)
|
||||
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
|
||||
val presenter = createRoomListPresenter(
|
||||
@@ -268,7 +271,7 @@ class RoomListPresenterTest {
|
||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
||||
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
|
||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
|
||||
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
|
||||
nextState.eventSink(RoomListEvents.DismissBanner)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
||||
scope.cancel()
|
||||
@@ -644,6 +647,10 @@ class RoomListPresenterTest {
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
logoutPresenter: DirectLogoutPresenter = object : DirectLogoutPresenter {
|
||||
@Composable
|
||||
override fun present() = aDirectLogoutState()
|
||||
},
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
networkMonitor = networkMonitor,
|
||||
@@ -671,5 +678,6 @@ class RoomListPresenterTest {
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
fullScreenIntentPermissionsPresenter = FakeFullScreenIntentPermissionsPresenter(),
|
||||
notificationCleaner = notificationCleaner,
|
||||
logoutPresenter = logoutPresenter,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class RoomListViewTest {
|
||||
|
||||
val close = rule.activity.getString(CommonStrings.action_close)
|
||||
rule.onNodeWithContentDescription(close).performClick()
|
||||
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
|
||||
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,7 +86,7 @@ class RoomListViewTest {
|
||||
|
||||
val close = rule.activity.getString(CommonStrings.action_close)
|
||||
rule.onNodeWithContentDescription(close).performClick()
|
||||
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
|
||||
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -232,6 +232,21 @@ class RoomListViewTest {
|
||||
listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on logout and migrate calls the migration clicked callback`() {
|
||||
val state = aRoomListState(
|
||||
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
|
||||
eventSink = {},
|
||||
)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomListView(
|
||||
state = state,
|
||||
onMigrateToNativeSlidingSyncClick = callback,
|
||||
)
|
||||
rule.clickOn(R.string.banner_migrate_to_native_sliding_sync_action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListView(
|
||||
@@ -244,6 +259,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
|
||||
onMigrateToNativeSlidingSyncClick: () -> Unit = EnsureNeverCalled()
|
||||
) {
|
||||
setContent {
|
||||
RoomListView(
|
||||
@@ -256,6 +272,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
onRoomSettingsClick = onRoomSettingsClick,
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
|
||||
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||
acceptDeclineInviteView = { },
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user