Merge branch 'develop' into feature/fga/pinned_messages_list_remove_reaction
This commit is contained in:
32
CHANGES.md
32
CHANGES.md
@@ -1,3 +1,35 @@
|
||||
Changes in Element X v0.5.3 (2024-09-10)
|
||||
========================================
|
||||
|
||||
### ✨ Features
|
||||
* Add banner for optional migration to simplified sliding sync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3429
|
||||
|
||||
### 🙌 Improvements
|
||||
* Timeline : remove the encrypted history banner by @ganfra in https://github.com/element-hq/element-x-android/pull/3410
|
||||
|
||||
### 🐛 Bugfixes
|
||||
* Fix new logins with Simplified SS using the proxy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3417
|
||||
* Ensure Call is not hang up when user is asked to grant system permissions by @bmarty in https://github.com/element-hq/element-x-android/pull/3419
|
||||
* Wait for a room with joined state in `/sync` after creating it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3421
|
||||
* [Bugfix] : fix self verification flow by @ganfra in https://github.com/element-hq/element-x-android/pull/3426
|
||||
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3425
|
||||
|
||||
### 🚧 In development 🚧
|
||||
* [Feature] Pinned messages list by @ganfra in https://github.com/element-hq/element-x-android/pull/3392
|
||||
* Pinned messages banner : adjust indicator to match design. by @ganfra in https://github.com/element-hq/element-x-android/pull/3415
|
||||
|
||||
### Dependency upgrades
|
||||
* Update plugin dependencycheck to v10.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3372
|
||||
* Update plugin detekt to v1.23.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3424
|
||||
|
||||
### Others
|
||||
* Delete old log files by @bmarty in https://github.com/element-hq/element-x-android/pull/3413
|
||||
* Recovery key formatting and wording iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3409
|
||||
* Change license to AGPL by @bmarty in https://github.com/element-hq/element-x-android/pull/3422
|
||||
* Remove Wait list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3428
|
||||
|
||||
Changes in Element X v0.5.2 (2024-09-05)
|
||||
=========================================
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
@@ -65,6 +66,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -96,6 +98,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val shareEntryPoint: ShareEntryPoint,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -225,6 +229,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data class IncomingShare(val intent: Intent) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -271,6 +278,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
override fun onRoomDirectorySearchClick() {
|
||||
backstack.push(NavTarget.RoomDirectorySearch)
|
||||
}
|
||||
|
||||
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
|
||||
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
|
||||
}
|
||||
}
|
||||
roomListEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
@@ -407,6 +418,20 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
||||
.build()
|
||||
}
|
||||
is NavTarget.LogoutForNativeSlidingSyncMigrationNeeded -> {
|
||||
val callback = object : LogoutEntryPoint.Callback {
|
||||
override fun onChangeRecoveryKeyClick() {
|
||||
backstack.push(NavTarget.SecureBackup())
|
||||
}
|
||||
}
|
||||
|
||||
logoutEntryPoint.nodeBuilder(this, buildContext)
|
||||
.onSuccessfulLogoutPendingAction {
|
||||
enableNativeSlidingSyncUseCase()
|
||||
}
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/40005030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40005030.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: mainly bug fixes.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ fun DialogLikeBannerMolecule(
|
||||
onSubmitClick: () -> Unit,
|
||||
onDismissClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
actionText: String = stringResource(CommonStrings.action_continue),
|
||||
) {
|
||||
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Surface(
|
||||
@@ -74,7 +75,7 @@ fun DialogLikeBannerMolecule(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
text = actionText,
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onSubmitClick,
|
||||
|
||||
@@ -126,4 +126,10 @@ interface MatrixClient : Closeable {
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<String>
|
||||
suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomPreview>
|
||||
|
||||
/** Returns `true` if the home server supports native sliding sync. */
|
||||
suspend fun isNativeSlidingSyncSupported(): Boolean
|
||||
|
||||
/** Returns `true` if the current session is using native sliding sync. */
|
||||
fun isUsingNativeSlidingSync(): Boolean
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ sealed interface VirtualTimelineItem {
|
||||
|
||||
data object ReadMarker : VirtualTimelineItem
|
||||
|
||||
data object EncryptedHistoryBanner : VirtualTimelineItem
|
||||
|
||||
data object RoomBeginning : VirtualTimelineItem
|
||||
|
||||
data object LastForwardIndicator : VirtualTimelineItem
|
||||
|
||||
@@ -87,13 +87,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.BackupState
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientException
|
||||
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
||||
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
||||
import org.matrix.rustcomponents.sdk.PowerLevels
|
||||
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
||||
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
@@ -179,7 +179,6 @@ class RustMatrixClient(
|
||||
systemClock = clock,
|
||||
roomContentForwarder = RoomContentForwarder(innerRoomListService),
|
||||
roomSyncSubscriber = roomSyncSubscriber,
|
||||
isKeyBackupEnabled = { client.encryption().use { it.backupState() == BackupState.ENABLED } },
|
||||
getSessionData = { sessionStore.getSession(sessionId.value)!! },
|
||||
)
|
||||
|
||||
@@ -530,6 +529,14 @@ class RustMatrixClient(
|
||||
})
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
override suspend fun isNativeSlidingSyncSupported(): Boolean {
|
||||
return client.availableSlidingSyncVersions().contains(SlidingSyncVersion.Native)
|
||||
}
|
||||
|
||||
override fun isUsingNativeSlidingSync(): Boolean {
|
||||
return client.session().slidingSyncVersion == SlidingSyncVersion.Native
|
||||
}
|
||||
|
||||
internal fun setDelegate(delegate: RustClientSessionDelegate) {
|
||||
client.setDelegate(delegate)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RustMatrixRoom(
|
||||
override val sessionId: SessionId,
|
||||
private val isKeyBackupEnabled: Boolean,
|
||||
private val roomListItem: RoomListItem,
|
||||
private val innerRoom: InnerRoom,
|
||||
innerTimeline: InnerTimeline,
|
||||
@@ -652,16 +651,14 @@ class RustMatrixRoom(
|
||||
): Timeline {
|
||||
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline")
|
||||
return RustTimeline(
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
mode = mode,
|
||||
matrixRoom = this,
|
||||
inner = timeline,
|
||||
systemClock = systemClock,
|
||||
coroutineScope = timelineCoroutineScope,
|
||||
dispatcher = roomDispatcher,
|
||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||
onNewSyncedEvent = onNewSyncedEvent,
|
||||
roomContentForwarder = roomContentForwarder,
|
||||
inner = timeline,
|
||||
onNewSyncedEvent = onNewSyncedEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ class RustRoomFactory(
|
||||
private val roomListService: RoomListService,
|
||||
private val innerRoomListService: InnerRoomListService,
|
||||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||
private val isKeyBackupEnabled: suspend () -> Boolean,
|
||||
private val getSessionData: suspend () -> SessionData,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -109,7 +108,6 @@ class RustRoomFactory(
|
||||
val liveTimeline = roomReferences.fullRoom.timeline()
|
||||
RustMatrixRoom(
|
||||
sessionId = sessionId,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled(),
|
||||
roomListItem = roomReferences.roomListItem,
|
||||
innerRoom = roomReferences.fullRoom,
|
||||
innerTimeline = liveTimeline,
|
||||
|
||||
@@ -40,7 +40,6 @@ import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTim
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
|
||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
@@ -70,7 +69,6 @@ import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
private const val PAGINATION_SIZE = 50
|
||||
@@ -79,11 +77,9 @@ class RustTimeline(
|
||||
private val inner: InnerTimeline,
|
||||
mode: Timeline.Mode,
|
||||
systemClock: SystemClock,
|
||||
isKeyBackupEnabled: Boolean,
|
||||
private val matrixRoom: MatrixRoom,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
lastLoginTimestamp: Date?,
|
||||
private val roomContentForwarder: RoomContentForwarder,
|
||||
onNewSyncedEvent: () -> Unit,
|
||||
) : Timeline {
|
||||
@@ -107,12 +103,6 @@ class RustTimeline(
|
||||
timelineItems = _timelineItems,
|
||||
timelineItemFactory = timelineItemMapper,
|
||||
)
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
private val timelineItemsSubscriber = TimelineItemsSubscriber(
|
||||
timeline = inner,
|
||||
timelineCoroutineScope = coroutineScope,
|
||||
@@ -219,7 +209,6 @@ class RustTimeline(
|
||||
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward, isInit ->
|
||||
withContext(dispatcher) {
|
||||
timelineItems
|
||||
.process { items -> encryptedHistoryPostProcessor.process(items) }
|
||||
.process { items ->
|
||||
roomBeginningPostProcessor.process(
|
||||
items = items,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
internal fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual &&
|
||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
@@ -19,11 +19,10 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
||||
hasMoreToLoadBackward: Boolean,
|
||||
hasMoreToLoadForward: Boolean,
|
||||
): List<MatrixTimelineItem> {
|
||||
val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner()
|
||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||
val currentTimestamp = systemClock.epochMillis()
|
||||
return buildList {
|
||||
if (shouldAddBackwardLoadingIndicator) {
|
||||
if (hasMoreToLoadBackward) {
|
||||
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||
uniqueId = UniqueId("BackwardLoadingIndicator"),
|
||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||
|
||||
@@ -36,7 +36,6 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
|
||||
}
|
||||
|
||||
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
if (items.hasEncryptionHistoryBanner()) return items
|
||||
val roomBeginningItem = createRoomBeginningItem()
|
||||
return listOf(roomBeginningItem) + items
|
||||
}
|
||||
|
||||
@@ -1,55 +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.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.Date
|
||||
|
||||
internal val encryptedHistoryBannerId = UniqueId("EncryptedHistoryBannerId")
|
||||
|
||||
class TimelineEncryptedHistoryPostProcessor(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val lastLoginTimestamp: Date?,
|
||||
private val isRoomEncrypted: Boolean,
|
||||
private val isKeyBackupEnabled: Boolean,
|
||||
) {
|
||||
suspend fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = withContext(dispatcher) {
|
||||
Timber.d("Process on Thread=${Thread.currentThread()}")
|
||||
if (!isRoomEncrypted || isKeyBackupEnabled || lastLoginTimestamp == null) return@withContext items
|
||||
replaceWithEncryptionHistoryBannerIfNeeded(items)
|
||||
}
|
||||
|
||||
private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
var lastEncryptedHistoryBannerIndex = -1
|
||||
for ((i, item) in list.withIndex()) {
|
||||
if (isItemEncryptionHistory(item)) {
|
||||
lastEncryptedHistoryBannerIndex = i
|
||||
}
|
||||
}
|
||||
return if (lastEncryptedHistoryBannerIndex >= 0) {
|
||||
val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList()
|
||||
sublist.add(0, MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||
sublist
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean {
|
||||
if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
||||
return true
|
||||
}
|
||||
val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false
|
||||
return timestamp <= lastLoginTimestamp!!.time
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
@@ -70,16 +69,6 @@ class RoomBeginningPostProcessorTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor will not add beginning of room item if it's not a DM and EncryptedHistoryBanner item is found`() {
|
||||
val timelineItems = listOf(
|
||||
MatrixTimelineItem.Virtual(UniqueId("EncryptedHistoryBanner"), VirtualTimelineItem.EncryptedHistoryBanner),
|
||||
)
|
||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
||||
assertThat(processedItems).isEqualTo(timelineItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processor won't remove items if it's not at the start of the timeline`() {
|
||||
val timelineItems = listOf(
|
||||
|
||||
@@ -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.libraries.matrix.impl.timeline.postprocessor
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class TimelineEncryptedHistoryPostProcessorTest {
|
||||
private val defaultLastLoginTimestamp = Date(1_689_061_264L)
|
||||
|
||||
@Test
|
||||
fun `given an unencrypted room, nothing is done`() = runTest {
|
||||
val processor = createPostProcessor(isRoomEncrypted = false)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given an encrypted room, and key backup enabled, nothing is done`() = runTest {
|
||||
val processor = createPostProcessor(isKeyBackupEnabled = true)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a null lastLoginTimestamp, nothing is done`() = runTest {
|
||||
val processor = createPostProcessor(lastLoginTimestamp = null)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given an empty list, nothing is done`() = runTest {
|
||||
val processor = createPostProcessor()
|
||||
val items = emptyList<MatrixTimelineItem>()
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with no items before lastLoginTimestamp, nothing is done`() = runTest {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() = runTest {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time))
|
||||
)
|
||||
assertThat(processor.process(items))
|
||||
.isEqualTo(listOf(MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() = runTest {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1))
|
||||
)
|
||||
assertThat(processor.process(items)).isEqualTo(
|
||||
listOf(MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, then they're replaced`() = runTest {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)),
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)),
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)),
|
||||
)
|
||||
assertThat(processor.process(items)).isEqualTo(
|
||||
listOf(
|
||||
MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner),
|
||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createPostProcessor(
|
||||
lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
|
||||
isRoomEncrypted: Boolean = true,
|
||||
isKeyBackupEnabled: Boolean = false,
|
||||
) = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = isRoomEncrypted,
|
||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
||||
dispatcher = StandardTestDispatcher(testScheduler)
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,8 @@ class FakeMatrixClient(
|
||||
private val clearCacheLambda: () -> Unit = { lambdaError() },
|
||||
private val userIdServerNameLambda: () -> String = { lambdaError() },
|
||||
private val getUrlLambda: (String) -> Result<String> = { lambdaError() },
|
||||
var isNativeSlidingSyncSupportedLambda: suspend () -> Boolean = { true },
|
||||
var isUsingNativeSlidingSyncLambda: () -> Boolean = { true },
|
||||
) : MatrixClient {
|
||||
var setDisplayNameCalled: Boolean = false
|
||||
private set
|
||||
@@ -316,4 +318,12 @@ class FakeMatrixClient(
|
||||
override suspend fun getUrl(url: String): Result<String> {
|
||||
return getUrlLambda(url)
|
||||
}
|
||||
|
||||
override suspend fun isNativeSlidingSyncSupported(): Boolean {
|
||||
return isNativeSlidingSyncSupportedLambda()
|
||||
}
|
||||
|
||||
override fun isUsingNativeSlidingSync(): Boolean {
|
||||
return isUsingNativeSlidingSyncLambda()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,8 @@ dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.libraries.preferences.api.store
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class EnableNativeSlidingSyncUseCase @Inject constructor(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
) {
|
||||
operator fun invoke() {
|
||||
appCoroutineScope.launch {
|
||||
appPreferencesStore.setSimplifiedSlidingSyncEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.libraries.preferences.api.store
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class EnableNativeSlidingSyncUseCaseTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `ensure that the use case sets the simplified sliding sync enabled flag`() = runTest {
|
||||
val preferencesStore = InMemoryAppPreferencesStore()
|
||||
val useCase = EnableNativeSlidingSyncUseCase(preferencesStore, this)
|
||||
assertThat(preferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
|
||||
|
||||
useCase()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(preferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ private const val versionMinor = 5
|
||||
// Note: even values are reserved for regular release, odd values for hotfix release.
|
||||
// When creating a hotfix, you should decrease the value, since the current value
|
||||
// is the value for the next regular release.
|
||||
private const val versionPatch = 3
|
||||
private const val versionPatch = 4
|
||||
|
||||
object Versions {
|
||||
val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch
|
||||
|
||||
@@ -56,6 +56,7 @@ dependencies {
|
||||
implementation(projects.features.roomlist.impl)
|
||||
implementation(projects.features.leaveroom.impl)
|
||||
implementation(projects.features.login.impl)
|
||||
implementation(projects.features.logout.impl)
|
||||
implementation(projects.features.networkmonitor.impl)
|
||||
implementation(projects.services.toolbox.impl)
|
||||
implementation(projects.libraries.featureflag.impl)
|
||||
|
||||
@@ -38,7 +38,6 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = {},
|
||||
onWaitListError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
|
||||
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
|
||||
import io.element.android.features.leaveroom.impl.DefaultLeaveRoomPresenter
|
||||
import io.element.android.features.logout.impl.direct.DefaultDirectLogoutPresenter
|
||||
import io.element.android.features.networkmonitor.impl.DefaultNetworkMonitor
|
||||
import io.element.android.features.roomlist.impl.RoomListPresenter
|
||||
import io.element.android.features.roomlist.impl.RoomListView
|
||||
@@ -144,6 +145,7 @@ class RoomListScreen(
|
||||
}
|
||||
},
|
||||
notificationCleaner = FakeNotificationCleaner(),
|
||||
logoutPresenter = DefaultDirectLogoutPresenter(matrixClient, encryptionService),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -172,7 +174,8 @@ class RoomListScreen(
|
||||
modifier = modifier,
|
||||
acceptDeclineInviteView = {
|
||||
AcceptDeclineInviteView(state = state.acceptDeclineInviteState, onAcceptInvite = {}, onDeclineInvite = {})
|
||||
}
|
||||
},
|
||||
onMigrateToNativeSlidingSyncClick = {},
|
||||
)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -139,6 +139,7 @@
|
||||
"session_verification_banner_.*",
|
||||
"confirm_recovery_key_banner_.*",
|
||||
"banner\\.set_up_recovery\\..*",
|
||||
"banner\\.migrate_to_native_sliding_sync\\..*",
|
||||
"full_screen_intent_banner_.*",
|
||||
"screen_migration_.*",
|
||||
"screen_invites_.*"
|
||||
|
||||
@@ -177,9 +177,8 @@ printf "Committing...\n"
|
||||
git commit -a -m 'version++'
|
||||
|
||||
printf "\n================================================================================\n"
|
||||
printf "Wait for the GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain to build the 'main' branch.\n"
|
||||
printf "Please enter the url of the github action (!!! WARNING: NOT THE URL OF THE ARTIFACT ANYMORE !!!)\n"
|
||||
read -p "For instance https://github.com/element-hq/element-x-android/actions/runs/9065756777: " runUrl
|
||||
printf "The GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain should have start a new run.\n"
|
||||
read -p "Please enter the url of the run, no need to wait for it to complete (example: https://github.com/element-hq/element-x-android/actions/runs/9065756777): " runUrl
|
||||
|
||||
targetPath="./tmp/Element/${version}"
|
||||
|
||||
@@ -270,7 +269,7 @@ printf "File app-fdroid-x86_64-release-signed.apk:\n"
|
||||
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk | grep package
|
||||
|
||||
printf "\n"
|
||||
read -p "Does it look correct? Press enter when it's done."
|
||||
read -p "Does it look correct? Press enter when it's done. "
|
||||
|
||||
printf "\n================================================================================\n"
|
||||
printf "The APKs in ${fdroidTargetPath} have been signed!\n"
|
||||
@@ -363,7 +362,7 @@ read -p ". Press enter to continue. "
|
||||
printf "\n================================================================================\n"
|
||||
printf "Update the project release notes:\n\n"
|
||||
|
||||
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. \n"
|
||||
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. "
|
||||
|
||||
printf "\n================================================================================\n"
|
||||
printf "Committing...\n"
|
||||
|
||||
Reference in New Issue
Block a user