Merge branch 'develop' into feature/fga/pinned_messages_list_remove_reaction

This commit is contained in:
ganfra
2024-09-10 11:25:40 +02:00
committed by GitHub
82 changed files with 375 additions and 907 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,5 +29,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onRoomSettingsClick(roomId: RoomId)
fun onReportBugClick()
fun onRoomDirectorySearchClick()
fun onLogoutForNativeSlidingSyncMigrationNeeded()
}
}

View File

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

View File

@@ -20,6 +20,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
aRoomsContentState(summaries = persistentListOf()),
aSkeletonContentState(),
anEmptyContentState(),
aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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