diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2027f2868a..0f6da36e26 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -42,18 +42,20 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.room.RoomFlowNode -import io.element.android.appnav.room.RoomLoadedFlowNode +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode 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.invitelist.api.InviteListEntryPoint +import io.element.android.features.invite.api.InviteListEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint @@ -82,6 +84,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import timber.log.Timber +import java.util.Optional @ContributesNode(SessionScope::class) class LoggedInFlowNode @AssistedInject constructor( @@ -213,7 +216,8 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data class Room( val roomId: RoomId, - val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages + val roomDescription: RoomDescription? = null, + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages ) : NavTarget @Parcelize @@ -273,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor( } override fun onRoomSettingsClicked(roomId: RoomId) { - backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomDetails)) + backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.Details)) } override fun onReportBugClicked() { @@ -290,7 +294,7 @@ class LoggedInFlowNode @AssistedInject constructor( .build() } is NavTarget.Room -> { - val callback = object : RoomLoadedFlowNode.Callback { + val callback = object : JoinedRoomLoadedFlowNode.Callback { override fun onOpenRoom(roomId: RoomId) { backstack.push(NavTarget.Room(roomId)) } @@ -303,7 +307,11 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) } } - val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) + val inputs = RoomFlowNode.Inputs( + roomId = navTarget.roomId, + roomDescription = Optional.ofNullable(navTarget.roomDescription), + initialElement = navTarget.initialElement + ) createNode(buildContext, plugins = listOf(inputs, callback)) } is NavTarget.Settings -> { @@ -317,7 +325,7 @@ class LoggedInFlowNode @AssistedInject constructor( } override fun onOpenRoomNotificationSettings(roomId: RoomId) { - backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings)) + backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.NotificationSettings)) } } val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) @@ -349,6 +357,10 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.pop() } + override fun onInviteClicked(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId)) + } + override fun onInviteAccepted(roomId: RoomId) { backstack.push(NavTarget.Room(roomId)) } @@ -370,8 +382,12 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.RoomDirectorySearch -> { roomDirectoryEntryPoint.nodeBuilder(this, buildContext) .callback(object : RoomDirectoryEntryPoint.Callback { - override fun onOpenRoom(roomId: RoomId) { - coroutineScope.launch { attachRoom(roomId) } + override fun onRoomJoined(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId)) + } + + override fun onResultClicked(roomDescription: RoomDescription) { + backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription)) } }) .build() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index b45207b034..db3664e631 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,13 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.appnav.room import android.os.Parcelable -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.modality.BuildContext @@ -36,102 +34,109 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.features.networkmonitor.api.NetworkMonitor -import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.appnav.room.joined.JoinedRoomFlowNode +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.coroutines.flow.distinctUntilChanged +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize +import timber.log.Timber +import java.util.Optional +import kotlin.jvm.optionals.getOrNull @ContributesNode(SessionScope::class) class RoomFlowNode @AssistedInject constructor( @Assisted val buildContext: BuildContext, @Assisted plugins: List, - loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, - private val networkMonitor: NetworkMonitor, -) : - BaseFlowNode( - backstack = BackStack( - initialElement = NavTarget.Loading, - savedStateMap = buildContext.savedStateMap, - ), - buildContext = buildContext, - plugins = plugins - ) { + private val client: MatrixClient, + private val roomMembershipObserver: RoomMembershipObserver, + private val joinRoomEntryPoint: JoinRoomEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { data class Inputs( val roomId: RoomId, - val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages, + val roomDescription: Optional, + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, ) : NodeInputs private val inputs: Inputs = inputs() - private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId) sealed interface NavTarget : Parcelable { @Parcelize data object Loading : NavTarget @Parcelize - data object Loaded : NavTarget + data object JoinRoom : NavTarget + + @Parcelize + data object JoinedRoom : NavTarget } override fun onBuilt() { super.onBuilt() - loadingRoomStateStateFlow - .map { - it is LoadingRoomState.Loaded + client.getRoomInfoFlow( + inputs.roomId + ).onEach { roomInfo -> + Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}") + if (roomInfo.getOrNull()?.currentUserMembership == CurrentUserMembership.JOINED) { + backstack.newRoot(NavTarget.JoinedRoom) + } else { + backstack.newRoot(NavTarget.JoinRoom) } - .distinctUntilChanged() - .onEach { isLoaded -> - if (isLoaded) { - backstack.newRoot(NavTarget.Loaded) - } else { - backstack.newRoot(NavTarget.Loading) - } + } + .launchIn(lifecycleScope) + + // When leaving the room from this session only, navigate up. + roomMembershipObserver.updates + .filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom } + .onEach { + navigateUp() } .launchIn(lifecycleScope) } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Loaded -> { - val roomFlowNodeCallback = plugins() - val awaitRoomState = loadingRoomStateStateFlow.value - if (awaitRoomState is LoadingRoomState.Loaded) { - val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) - createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) - } else { - loadingNode(buildContext, this::navigateUp) - } + NavTarget.Loading -> loadingNode(buildContext) + NavTarget.JoinRoom -> { + val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription) + joinRoomEntryPoint.createNode(this, buildContext, inputs) } - NavTarget.Loading -> { - loadingNode(buildContext, this::navigateUp) + NavTarget.JoinedRoom -> { + val roomFlowNodeCallback = plugins() + val inputs = JoinedRoomFlowNode.Inputs(inputs.roomId, initialElement = inputs.initialElement) + createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) } } } - private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> - val loadingRoomState by loadingRoomStateStateFlow.collectAsState() - val networkStatus by networkMonitor.connectivity.collectAsState() - LoadingRoomNodeView( - state = loadingRoomState, - hasNetworkConnection = networkStatus == NetworkStatus.Online, - modifier = modifier, - onBackClicked = onBackClicked - ) + private fun loadingNode(buildContext: BuildContext) = node(buildContext) { + Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } @Composable override fun View(modifier: Modifier) { - BackstackView( - transitionHandler = JumpToEndTransitionHandler(), - ) + BackstackView(transitionHandler = JumpToEndTransitionHandler()) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt new file mode 100644 index 0000000000..901b2667be --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.room + +enum class RoomNavigationTarget { + Messages, + Details, + NotificationSettings, +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt new file mode 100644 index 0000000000..36def888ac --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.appnav.room.joined + +import android.os.Parcelable +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class JoinedRoomFlowNode @AssistedInject constructor( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, + private val networkMonitor: NetworkMonitor, +) : + BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + data class Inputs( + val roomId: RoomId, + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId) + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Loading : NavTarget + + @Parcelize + data object Loaded : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + loadingRoomStateStateFlow + .map { + it is LoadingRoomState.Loaded + } + .distinctUntilChanged() + .onEach { isLoaded -> + if (isLoaded) { + backstack.newRoot(NavTarget.Loaded) + } else { + backstack.newRoot(NavTarget.Loading) + } + } + .launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Loaded -> { + val roomFlowNodeCallback = plugins() + val awaitRoomState = loadingRoomStateStateFlow.value + if (awaitRoomState is LoadingRoomState.Loaded) { + val inputs = JoinedRoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) + createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) + } else { + loadingNode(buildContext, this::navigateUp) + } + } + NavTarget.Loading -> { + loadingNode(buildContext, this::navigateUp) + } + } + } + + private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> + val loadingRoomState by loadingRoomStateStateFlow.collectAsState() + val networkStatus by networkMonitor.connectivity.collectAsState() + LoadingRoomNodeView( + state = loadingRoomState, + hasNetworkConnection = networkStatus == NetworkStatus.Online, + modifier = modifier, + onBackClicked = onBackClicked + ) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView( + transitionHandler = JumpToEndTransitionHandler(), + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt similarity index 89% rename from appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt rename to appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index b9a537dc24..a5d7893c91 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.appnav.room +package io.element.android.appnav.room.joined import android.os.Parcelable import androidx.compose.runtime.Composable @@ -32,6 +32,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.di.RoomComponentFactory +import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.BackstackView @@ -44,18 +45,14 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @ContributesNode(SessionScope::class) -class RoomLoadedFlowNode @AssistedInject constructor( +class JoinedRoomLoadedFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val messagesEntryPoint: MessagesEntryPoint, @@ -64,10 +61,13 @@ class RoomLoadedFlowNode @AssistedInject constructor( private val appCoroutineScope: CoroutineScope, private val matrixClient: MatrixClient, roomComponentFactory: RoomComponentFactory, - roomMembershipObserver: RoomMembershipObserver, -) : BaseFlowNode( +) : BaseFlowNode( backstack = BackStack( - initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement, + initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) { + RoomNavigationTarget.Messages -> NavTarget.Messages + RoomNavigationTarget.Details -> NavTarget.RoomDetails + RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings + }, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -81,7 +81,7 @@ class RoomLoadedFlowNode @AssistedInject constructor( data class Inputs( val room: MatrixRoom, - val initialElement: NavTarget = NavTarget.Messages, + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, ) : NodeInputs private val inputs: Inputs = inputs() @@ -111,13 +111,6 @@ class RoomLoadedFlowNode @AssistedInject constructor( appNavigationStateService.onLeavingRoom(id) } ) - roomMembershipObserver.updates - .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom } - .onEach { - navigateUp() - } - .launchIn(lifecycleScope) - inputs() } private fun trackVisitedRoom() = lifecycleScope.launch { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt similarity index 97% rename from appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt rename to appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt index 8db8d608b2..14fb955cb5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.appnav.room +package io.element.android.appnav.room.joined import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt similarity index 96% rename from appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt rename to appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt index 0798979812..dbc190354f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.appnav.room +package io.element.android.appnav.room.joined import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.di.SessionScope diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt similarity index 85% rename from appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt index 0595ebdbea..08702eeb5a 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt @@ -27,12 +27,11 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.google.common.truth.Truth.assertThat import io.element.android.appnav.di.RoomComponentFactory -import io.element.android.appnav.room.RoomLoadedFlowNode +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.childNode import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.services.appnavstate.test.FakeAppNavigationStateService @@ -41,7 +40,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class RoomFlowNodeTest { +class JoinRoomLoadedFlowNodeTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @@ -88,18 +87,17 @@ class RoomFlowNodeTest { } } - private fun aRoomFlowNode( + private fun createJoinedRoomLoadedFlowNode( plugins: List, messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), coroutineScope: CoroutineScope, - ) = RoomLoadedFlowNode( + ) = JoinedRoomLoadedFlowNode( buildContext = BuildContext.root(savedStateMap = null), plugins = plugins, messagesEntryPoint = messagesEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint, appNavigationStateService = FakeAppNavigationStateService(), - roomMembershipObserver = RoomMembershipObserver(), appCoroutineScope = coroutineScope, roomComponentFactory = FakeRoomComponentFactory(), matrixClient = FakeMatrixClient(), @@ -110,8 +108,8 @@ class RoomFlowNodeTest { // GIVEN val room = FakeMatrixRoom() val fakeMessagesEntryPoint = FakeMessagesEntryPoint() - val inputs = RoomLoadedFlowNode.Inputs(room) - val roomFlowNode = aRoomFlowNode( + val inputs = JoinedRoomLoadedFlowNode.Inputs(room) + val roomFlowNode = createJoinedRoomLoadedFlowNode( plugins = listOf(inputs), messagesEntryPoint = fakeMessagesEntryPoint, coroutineScope = this @@ -120,9 +118,9 @@ class RoomFlowNodeTest { val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() // THEN - assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages) - roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) - val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!! + assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages) + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) + val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!! assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) } @@ -132,8 +130,8 @@ class RoomFlowNodeTest { val room = FakeMatrixRoom() val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() - val inputs = RoomLoadedFlowNode.Inputs(room) - val roomFlowNode = aRoomFlowNode( + val inputs = JoinedRoomLoadedFlowNode.Inputs(room) + val roomFlowNode = createJoinedRoomLoadedFlowNode( plugins = listOf(inputs), messagesEntryPoint = fakeMessagesEntryPoint, roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, @@ -143,8 +141,8 @@ class RoomFlowNodeTest { // WHEN fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() // THEN - roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) - val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!! + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) + val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt index 58e35b2d58..cb8ed2da32 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt @@ -18,6 +18,8 @@ package io.element.android.appnav.room import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.room.joined.LoadingRoomState +import io.element.android.appnav.room.joined.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID diff --git a/features/invitelist/api/build.gradle.kts b/features/invite/api/build.gradle.kts similarity index 88% rename from features/invitelist/api/build.gradle.kts rename to features/invite/api/build.gradle.kts index 6ea2b8a49d..52df82e38a 100644 --- a/features/invitelist/api/build.gradle.kts +++ b/features/invite/api/build.gradle.kts @@ -15,11 +15,11 @@ */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { - namespace = "io.element.android.features.invitelist.api" + namespace = "io.element.android.features.invite.api" } dependencies { diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt similarity index 93% rename from features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt rename to features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt index 95d2c94f50..c5063cdbc8 100644 --- a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteListEntryPoint.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.api +package io.element.android.features.invite.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -32,7 +32,7 @@ interface InviteListEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onBackClicked() - + fun onInviteClicked(roomId: RoomId) fun onInviteAccepted(roomId: RoomId) } } diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt similarity index 94% rename from features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt rename to features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt index ac143b8740..e34b17cee8 100644 --- a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.api +package io.element.android.features.invite.api import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.Flow diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt new file mode 100644 index 0000000000..8365ba3a90 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.api.response + +interface AcceptDeclineInviteEvents { + data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents + data class DeclineInvite(val invite: InviteData) : AcceptDeclineInviteEvents +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt new file mode 100644 index 0000000000..95980f0ac3 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.api.response + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +data class AcceptDeclineInviteState( + val invite: Optional, + val acceptAction: AsyncAction, + val declineAction: AsyncAction, + val eventSink: (AcceptDeclineInviteEvents) -> Unit, +) diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt new file mode 100644 index 0000000000..af2c37e68f --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.api.response + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +open class AcceptDeclineInviteStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAcceptDeclineInviteState(), + anAcceptDeclineInviteState( + invite = Optional.of( + InviteData(RoomId("!room:matrix.org"), isDirect = true, roomName = "Alice"), + ), + declineAction = AsyncAction.Confirming, + ), + anAcceptDeclineInviteState( + invite = Optional.of( + InviteData(RoomId("!room:matrix.org"), isDirect = false, roomName = "Some room"), + ), + declineAction = AsyncAction.Confirming, + ), + anAcceptDeclineInviteState( + acceptAction = AsyncAction.Failure(Throwable("Whoops")), + ), + anAcceptDeclineInviteState( + declineAction = AsyncAction.Failure(Throwable("Whoops")), + ), + ) +} + +fun anAcceptDeclineInviteState( + invite: Optional = Optional.empty(), + acceptAction: AsyncAction = AsyncAction.Uninitialized, + declineAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AcceptDeclineInviteEvents) -> Unit = {} +) = AcceptDeclineInviteState( + invite = invite, + acceptAction = acceptAction, + declineAction = declineAction, + eventSink = eventSink, +) diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt new file mode 100644 index 0000000000..c969458122 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.api.response + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.core.RoomId + +interface AcceptDeclineInviteView { + @Composable + fun Render( + state: AcceptDeclineInviteState, + onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, + modifier: Modifier, + ) +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt new file mode 100644 index 0000000000..081da0bfc6 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/InviteData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.api.response + +import io.element.android.libraries.matrix.api.core.RoomId + +data class InviteData( + val roomId: RoomId, + val roomName: String, + val isDirect: Boolean, +) diff --git a/features/invitelist/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts similarity index 91% rename from features/invitelist/impl/build.gradle.kts rename to features/invite/impl/build.gradle.kts index 110eb7946e..7a0b0db372 100644 --- a/features/invitelist/impl/build.gradle.kts +++ b/features/invite/impl/build.gradle.kts @@ -22,7 +22,7 @@ plugins { } android { - namespace = "io.element.android.features.invitelist.impl" + namespace = "io.element.android.features.invite.impl" } anvil { @@ -32,7 +32,7 @@ anvil { dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) - api(projects.features.invitelist.api) + api(projects.features.invite.api) implementation(libs.androidx.datastore.preferences) implementation(projects.libraries.core) implementation(projects.libraries.architecture) @@ -50,7 +50,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) - testImplementation(projects.features.invitelist.test) + testImplementation(projects.features.invite.test) testImplementation(projects.services.analytics.test) testImplementation(projects.tests.testutils) diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt similarity index 89% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt index 5c2fc780d7..5e464a79bc 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultInviteListEntryPoint.kt @@ -14,13 +14,14 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.features.invitelist.api.InviteListEntryPoint +import io.element.android.features.invite.api.InviteListEntryPoint +import io.element.android.features.invite.impl.invitelist.InviteListNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope import javax.inject.Inject diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt similarity index 94% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt index 1564b2fa83..bf0423914e 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl import android.content.Context import androidx.datastore.core.DataStore @@ -23,7 +23,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt similarity index 94% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt index 7c3c1ad1db..0987f6cf57 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/components/InviteSummaryRow.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl.components +package io.element.android.features.invite.impl.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,10 +40,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.invitelist.impl.R -import io.element.android.features.invitelist.impl.model.InviteListInviteSummary -import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider -import io.element.android.features.invitelist.impl.model.InviteSender +import io.element.android.features.invite.impl.R +import io.element.android.features.invite.impl.model.InviteListInviteSummary +import io.element.android.features.invite.impl.model.InviteListInviteSummaryProvider +import io.element.android.features.invite.impl.model.InviteSender import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.preview.ElementPreview diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt new file mode 100644 index 0000000000..fb956e13a4 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@Module +interface InviteModule { + @Binds + fun bindAcceptDeclinePresenter(presenter: AcceptDeclineInvitePresenter): Presenter +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt similarity index 66% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt index ae4b74bb2c..f4ba30844a 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListEvents.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,11 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist -import io.element.android.features.invitelist.impl.model.InviteListInviteSummary +import io.element.android.features.invite.impl.model.InviteListInviteSummary sealed interface InviteListEvents { data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents - - data object ConfirmDeclineInvite : InviteListEvents - data object CancelDeclineInvite : InviteListEvents - - data object DismissAcceptError : InviteListEvents - data object DismissDeclineError : InviteListEvents } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt similarity index 82% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt index 31d742e4e7..fe491b157e 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListNode.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -25,7 +25,7 @@ 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.invitelist.api.InviteListEntryPoint +import io.element.android.features.invite.api.InviteListEntryPoint import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId @@ -43,6 +43,10 @@ class InviteListNode @AssistedInject constructor( plugins().forEach { it.onInviteAccepted(roomId) } } + private fun onInviteClicked(roomId: RoomId) { + plugins().forEach { it.onInviteClicked(roomId) } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -50,6 +54,8 @@ class InviteListNode @AssistedInject constructor( state = state, onBackClicked = ::onBackClicked, onInviteAccepted = ::onInviteAccepted, + onInviteDeclined = {}, + onInviteClicked = ::onInviteClicked, ) } } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt similarity index 53% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt index aa7cead66e..9d9a33ef13 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,43 +14,35 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import im.vector.app.features.analytics.plan.JoinedRoom -import io.element.android.features.invitelist.api.SeenInvitesStore -import io.element.android.features.invitelist.impl.model.InviteListInviteSummary -import io.element.android.features.invitelist.impl.model.InviteSender -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.InviteData +import io.element.android.features.invite.impl.model.InviteListInviteSummary +import io.element.android.features.invite.impl.model.InviteSender import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import io.element.android.libraries.push.api.notifications.NotificationDrawerManager -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import javax.inject.Inject class InviteListPresenter @Inject constructor( private val client: MatrixClient, private val store: SeenInvitesStore, - private val analyticsService: AnalyticsService, - private val notificationDrawerManager: NotificationDrawerManager, + private val acceptDeclineInvitePresenter: Presenter, ) : Presenter { @Composable override fun present(): InviteListState { @@ -75,40 +67,20 @@ class InviteListPresenter @Inject constructor( ) } - val localCoroutineScope = rememberCoroutineScope() - val acceptedAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } - val declinedAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } - val decliningInvite: MutableState = remember { mutableStateOf(null) } + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() fun handleEvent(event: InviteListEvents) { when (event) { is InviteListEvents.AcceptInvite -> { - acceptedAction.value = AsyncData.Uninitialized - localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData()) + ) } is InviteListEvents.DeclineInvite -> { - decliningInvite.value = event.invite - } - - is InviteListEvents.ConfirmDeclineInvite -> { - declinedAction.value = AsyncData.Uninitialized - decliningInvite.value?.let { - localCoroutineScope.declineInvite(it.roomId, declinedAction) - } - decliningInvite.value = null - } - - is InviteListEvents.CancelDeclineInvite -> { - decliningInvite.value = null - } - - is InviteListEvents.DismissAcceptError -> { - acceptedAction.value = AsyncData.Uninitialized - } - - is InviteListEvents.DismissDeclineError -> { - declinedAction.value = AsyncData.Uninitialized + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData()) + ) } } } @@ -124,38 +96,11 @@ class InviteListPresenter @Inject constructor( return InviteListState( inviteList = inviteList, - declineConfirmationDialog = decliningInvite.value?.let { - InviteDeclineConfirmationDialog.Visible( - isDirect = it.isDirect, - name = it.roomName, - ) - } ?: InviteDeclineConfirmationDialog.Hidden, - acceptedAction = acceptedAction.value, - declinedAction = declinedAction.value, + acceptDeclineInviteState = acceptDeclineInviteState, eventSink = ::handleEvent ) } - private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch { - suspend { - client.getRoom(roomId)?.use { - it.join().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) - analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) - } - roomId - }.runCatchingUpdatingState(acceptedAction) - } - - private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch { - suspend { - client.getRoom(roomId)?.use { - it.leave().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) - }.let { } - }.runCatchingUpdatingState(declinedAction) - } - private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run { val i = inviter val avatarData = if (isDirect && i != null) { @@ -203,4 +148,10 @@ class InviteListPresenter @Inject constructor( }, ) } + + private fun InviteListInviteSummary.toInviteData() = InviteData( + roomId = roomId, + roomName = roomName, + isDirect = isDirect, + ) } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt similarity index 55% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt index b576861475..8a3cd69923 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,24 +14,16 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.runtime.Immutable -import io.element.android.features.invitelist.impl.model.InviteListInviteSummary -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.impl.model.InviteListInviteSummary import kotlinx.collections.immutable.ImmutableList @Immutable data class InviteListState( val inviteList: ImmutableList, - val declineConfirmationDialog: InviteDeclineConfirmationDialog, - val acceptedAction: AsyncData, - val declinedAction: AsyncData, + val acceptDeclineInviteState: AcceptDeclineInviteState, val eventSink: (InviteListEvents) -> Unit ) - -sealed interface InviteDeclineConfirmationDialog { - data object Hidden : InviteDeclineConfirmationDialog - data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog -} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt similarity index 60% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt index e61bd9ff93..9814b1b20d 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListStateProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,35 +14,39 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.invitelist.impl.model.InviteListInviteSummary -import io.element.android.features.invitelist.impl.model.InviteSender -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider +import io.element.android.features.invite.api.response.anAcceptDeclineInviteState +import io.element.android.features.invite.impl.model.InviteListInviteSummary +import io.element.android.features.invite.impl.model.InviteSender import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf open class InviteListStateProvider : PreviewParameterProvider { + private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider() + override val values: Sequence get() = sequenceOf( - aInviteListState(), - aInviteListState().copy(inviteList = persistentListOf()), - aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")), - aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")), - aInviteListState().copy(acceptedAction = AsyncData.Failure(Throwable("Whoops"))), - aInviteListState().copy(declinedAction = AsyncData.Failure(Throwable("Whoops"))), - ) + anInviteListState(), + anInviteListState(inviteList = persistentListOf()), + ) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState -> + anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState) + } } -internal fun aInviteListState() = InviteListState( - inviteList = aInviteListInviteSummaryList(), - declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, - acceptedAction = AsyncData.Uninitialized, - declinedAction = AsyncData.Uninitialized, - eventSink = {}, +internal fun anInviteListState( + inviteList: ImmutableList = aInviteListInviteSummaryList(), + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + eventSink: (InviteListEvents) -> Unit = {} +) = InviteListState( + inviteList = inviteList, + acceptDeclineInviteState = acceptDeclineInviteState, + eventSink = eventSink, ) internal fun aInviteListInviteSummaryList(): ImmutableList { diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt similarity index 64% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt index ff8ba55c0b..16031d558c 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/invitelist/InviteListView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,9 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl +package io.element.android.features.invite.impl.invitelist +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -27,20 +28,16 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.invitelist.impl.components.InviteSummaryRow -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.invite.impl.R +import io.element.android.features.invite.impl.components.InviteSummaryRow +import io.element.android.features.invite.impl.response.AcceptDeclineInviteView import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.aliasScreenTitle @@ -56,61 +53,21 @@ fun InviteListView( state: InviteListState, onBackClicked: () -> Unit, onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, + onInviteClicked: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { - if (state.acceptedAction is AsyncData.Success) { - val latestOnInviteAccepted by rememberUpdatedState(onInviteAccepted) - LaunchedEffect(state.acceptedAction) { - latestOnInviteAccepted(state.acceptedAction.data) - } - } - InviteListContent( state = state, modifier = modifier, + onInviteClicked = onInviteClicked, onBackClicked = onBackClicked, ) - - if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) { - val contentResource = if (state.declineConfirmationDialog.isDirect) { - R.string.screen_invites_decline_direct_chat_message - } else { - R.string.screen_invites_decline_chat_message - } - - val titleResource = if (state.declineConfirmationDialog.isDirect) { - R.string.screen_invites_decline_direct_chat_title - } else { - R.string.screen_invites_decline_chat_title - } - - ConfirmationDialog( - content = stringResource(contentResource, state.declineConfirmationDialog.name), - title = stringResource(titleResource), - submitText = stringResource(CommonStrings.action_decline), - cancelText = stringResource(CommonStrings.action_cancel), - onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, - onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } - ) - } - - if (state.acceptedAction is AsyncData.Failure) { - ErrorDialog( - content = stringResource(CommonStrings.error_unknown), - title = stringResource(CommonStrings.common_error), - submitText = stringResource(CommonStrings.action_ok), - onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) } - ) - } - - if (state.declinedAction is AsyncData.Failure) { - ErrorDialog( - content = stringResource(CommonStrings.error_unknown), - title = stringResource(CommonStrings.common_error), - submitText = stringResource(CommonStrings.action_ok), - onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) } - ) - } + AcceptDeclineInviteView( + state = state.acceptDeclineInviteState, + onInviteAccepted = onInviteAccepted, + onInviteDeclined = onInviteDeclined, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -118,6 +75,7 @@ fun InviteListView( private fun InviteListContent( state: InviteListState, onBackClicked: () -> Unit, + onInviteClicked: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -138,8 +96,8 @@ private fun InviteListContent( content = { padding -> Column( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) { if (state.inviteList.isEmpty()) { Spacer(Modifier.size(80.dp)) @@ -158,6 +116,9 @@ private fun InviteListContent( items = state.inviteList, ) { index, invite -> InviteSummaryRow( + modifier = Modifier.clickable( + onClick = { onInviteClicked(invite.roomId) } + ), invite = invite, onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) }, onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) }, @@ -181,5 +142,7 @@ internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::cl state = state, onBackClicked = {}, onInviteAccepted = {}, + onInviteDeclined = {}, + onInviteClicked = {}, ) } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt similarity index 96% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt index 9f74b90142..e17dcc997c 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummary.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl.model +package io.element.android.features.invite.impl.model import androidx.compose.runtime.Immutable import io.element.android.libraries.designsystem.components.avatar.AvatarData diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt similarity index 96% rename from features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt index c872d05817..11f6742345 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/model/InviteListInviteSummaryProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.invitelist.impl.model +package io.element.android.features.invite.impl.model import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.matrix.api.core.RoomId diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt new file mode 100644 index 0000000000..5f7e80b7b1 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.response + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.InviteData +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class AcceptDeclineInvitePresenter @Inject constructor( + private val client: MatrixClient, + private val analyticsService: AnalyticsService, + private val notificationDrawerManager: NotificationDrawerManager, +) : Presenter { + @Composable + override fun present(): AcceptDeclineInviteState { + val localCoroutineScope = rememberCoroutineScope() + val acceptedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val declinedAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + var currentInvite by remember { + mutableStateOf>(Optional.empty()) + } + + fun handleEvents(event: AcceptDeclineInviteEvents) { + when (event) { + is AcceptDeclineInviteEvents.AcceptInvite -> { + currentInvite = Optional.of(event.invite) + localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + currentInvite = Optional.empty() + } + + is AcceptDeclineInviteEvents.DeclineInvite -> { + currentInvite = Optional.of(event.invite) + declinedAction.value = AsyncAction.Confirming + } + + is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> { + declinedAction.value = AsyncAction.Uninitialized + currentInvite.getOrNull()?.let { + localCoroutineScope.declineInvite(it.roomId, declinedAction) + } + currentInvite = Optional.empty() + } + + is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> { + currentInvite = Optional.empty() + declinedAction.value = AsyncAction.Uninitialized + } + + is InternalAcceptDeclineInviteEvents.DismissAcceptError -> { + acceptedAction.value = AsyncAction.Uninitialized + } + + is InternalAcceptDeclineInviteEvents.DismissDeclineError -> { + declinedAction.value = AsyncAction.Uninitialized + } + } + } + + return AcceptDeclineInviteState( + invite = currentInvite, + acceptAction = acceptedAction.value, + declineAction = declinedAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch { + acceptedAction.runUpdatingState { + client.joinRoom(roomId).onSuccess { + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + client.getRoom(roomId)?.use { room -> + analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) + } + } + } + } + + private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch { + suspend { + client.getRoom(roomId)?.use { + it.leave().getOrThrow() + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + } + roomId + }.runCatchingUpdatingState(declinedAction) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt new file mode 100644 index 0000000000..e0ad70ddc5 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.response + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider +import io.element.android.features.invite.api.response.InviteData +import io.element.android.features.invite.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.jvm.optionals.getOrNull + +@Composable +fun AcceptDeclineInviteView( + state: AcceptDeclineInviteState, + onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncActionView( + async = state.acceptAction, + onSuccess = onInviteAccepted, + onErrorDismiss = { + state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError) + }, + ) + AsyncActionView( + async = state.declineAction, + onSuccess = onInviteDeclined, + onErrorDismiss = { + state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError) + }, + confirmationDialog = { + val invite = state.invite.getOrNull() + if (invite != null) { + DeclineConfirmationDialog( + invite = invite, + onConfirmClicked = { + state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite) + }, + onDismissClicked = { + state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) + } + ) + } + } + ) + } +} + +@Composable +private fun DeclineConfirmationDialog( + invite: InviteData, + onConfirmClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier +) { + val contentResource = if (invite.isDirect) { + R.string.screen_invites_decline_direct_chat_message + } else { + R.string.screen_invites_decline_chat_message + } + + val titleResource = if (invite.isDirect) { + R.string.screen_invites_decline_direct_chat_title + } else { + R.string.screen_invites_decline_chat_title + } + + ConfirmationDialog( + modifier = modifier, + content = stringResource(contentResource, invite.roomName), + title = stringResource(titleResource), + submitText = stringResource(CommonStrings.action_decline), + cancelText = stringResource(CommonStrings.action_cancel), + onSubmitClicked = onConfirmClicked, + onDismiss = onDismissClicked, + ) +} + +@PreviewLightDark +@Composable +internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) = + ElementPreview { + AcceptDeclineInviteView( + state = state, + onInviteAccepted = {}, + onInviteDeclined = {}, + ) + } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt new file mode 100644 index 0000000000..a86b220364 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.response + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.AcceptDeclineInviteView +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class AcceptDeclineInviteViewWrapper @Inject constructor() : AcceptDeclineInviteView { + @Composable + override fun Render( + state: AcceptDeclineInviteState, + onInviteAccepted: (RoomId) -> Unit, + onInviteDeclined: (RoomId) -> Unit, + modifier: Modifier, + ) { + AcceptDeclineInviteView( + state = state, + onInviteAccepted = onInviteAccepted, + onInviteDeclined = onInviteDeclined, + modifier = modifier + ) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt new file mode 100644 index 0000000000..1ccf2ee34f --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/InternalAcceptDeclineInviteEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.response + +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents + +sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { + data object ConfirmDeclineInvite : InternalAcceptDeclineInviteEvents + data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents + data object DismissAcceptError : InternalAcceptDeclineInviteEvents + data object DismissDeclineError : InternalAcceptDeclineInviteEvents +} diff --git a/features/invitelist/impl/src/main/res/values-be/translations.xml b/features/invite/impl/src/main/res/values-be/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-be/translations.xml rename to features/invite/impl/src/main/res/values-be/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-bg/translations.xml b/features/invite/impl/src/main/res/values-bg/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-bg/translations.xml rename to features/invite/impl/src/main/res/values-bg/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-cs/translations.xml b/features/invite/impl/src/main/res/values-cs/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-cs/translations.xml rename to features/invite/impl/src/main/res/values-cs/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invite/impl/src/main/res/values-de/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-de/translations.xml rename to features/invite/impl/src/main/res/values-de/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-es/translations.xml b/features/invite/impl/src/main/res/values-es/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-es/translations.xml rename to features/invite/impl/src/main/res/values-es/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-fr/translations.xml b/features/invite/impl/src/main/res/values-fr/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-fr/translations.xml rename to features/invite/impl/src/main/res/values-fr/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-hu/translations.xml b/features/invite/impl/src/main/res/values-hu/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-hu/translations.xml rename to features/invite/impl/src/main/res/values-hu/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-in/translations.xml b/features/invite/impl/src/main/res/values-in/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-in/translations.xml rename to features/invite/impl/src/main/res/values-in/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-it/translations.xml b/features/invite/impl/src/main/res/values-it/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-it/translations.xml rename to features/invite/impl/src/main/res/values-it/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-ro/translations.xml b/features/invite/impl/src/main/res/values-ro/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-ro/translations.xml rename to features/invite/impl/src/main/res/values-ro/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-ru/translations.xml b/features/invite/impl/src/main/res/values-ru/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-ru/translations.xml rename to features/invite/impl/src/main/res/values-ru/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-sk/translations.xml b/features/invite/impl/src/main/res/values-sk/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-sk/translations.xml rename to features/invite/impl/src/main/res/values-sk/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-sv/translations.xml b/features/invite/impl/src/main/res/values-sv/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-sv/translations.xml rename to features/invite/impl/src/main/res/values-sv/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-uk/translations.xml b/features/invite/impl/src/main/res/values-uk/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-uk/translations.xml rename to features/invite/impl/src/main/res/values-uk/translations.xml diff --git a/features/invitelist/impl/src/main/res/values-zh-rTW/translations.xml b/features/invite/impl/src/main/res/values-zh-rTW/translations.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values-zh-rTW/translations.xml rename to features/invite/impl/src/main/res/values-zh-rTW/translations.xml diff --git a/features/invitelist/impl/src/main/res/values/localazy.xml b/features/invite/impl/src/main/res/values/localazy.xml similarity index 100% rename from features/invitelist/impl/src/main/res/values/localazy.xml rename to features/invite/impl/src/main/res/values/localazy.xml diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt new file mode 100644 index 0000000000..c984e9c5fb --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/invitelist/InviteListPresenterTests.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.invitelist + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.anAcceptDeclineInviteState +import io.element.android.features.invite.test.FakeSeenInvitesStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class InviteListPresenterTests { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - starts empty, adds invites when received`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createInviteListPresenter( + FakeMatrixClient(roomListService = roomListService) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.inviteList).isEmpty() + + roomListService.postInviteRooms(listOf(aRoomSummary())) + + val withInviteState = awaitItem() + assertThat(withInviteState.inviteList.size).isEqualTo(1) + assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) + } + } + + @Test + fun `present - uses user ID and avatar for direct invites`() = runTest { + val roomListService = FakeRoomListService().withDirectChatInvitation() + val presenter = createInviteListPresenter( + FakeMatrixClient(roomListService = roomListService) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val withInviteState = awaitInitialItem() + assertThat(withInviteState.inviteList.size).isEqualTo(1) + assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value) + assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) + assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo( + AvatarData( + id = A_USER_ID.value, + name = A_USER_NAME, + url = AN_AVATAR_URL, + size = AvatarSize.RoomInviteItem, + ) + ) + assertThat(withInviteState.inviteList[0].sender).isNull() + } + } + + @Test + fun `present - includes sender details for room invites`() = runTest { + val roomListService = FakeRoomListService().withRoomInvitation() + val presenter = createInviteListPresenter( + FakeMatrixClient(roomListService = roomListService) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val withInviteState = awaitInitialItem() + assertThat(withInviteState.inviteList.size).isEqualTo(1) + assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME) + assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID) + assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo( + AvatarData( + id = A_USER_ID.value, + name = A_USER_NAME, + url = AN_AVATAR_URL, + size = AvatarSize.InviteSender, + ) + ) + } + } + + @Test + fun `present - stores seen invites when received`() = runTest { + val roomListService = FakeRoomListService() + val store = FakeSeenInvitesStore() + val presenter = createInviteListPresenter( + FakeMatrixClient( + roomListService = roomListService, + ), + store, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() + + // When one invite is received, that ID is saved + roomListService.postInviteRooms(listOf(aRoomSummary())) + + awaitItem() + assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID)) + + // When a second is added, both are saved + roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + + awaitItem() + assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2)) + + // When they're both dismissed, an empty set is saved + roomListService.postInviteRooms(listOf()) + + awaitItem() + assertThat(store.getProvidedRoomIds()).isEmpty() + } + } + + @Test + fun `present - marks invite as new if they're unseen`() = runTest { + val roomListService = FakeRoomListService() + val store = FakeSeenInvitesStore() + store.publishRoomIds(setOf(A_ROOM_ID)) + val presenter = createInviteListPresenter( + FakeMatrixClient( + roomListService = roomListService, + ), + store, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() + + roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + skipItems(1) + + val withInviteState = awaitItem() + assertThat(withInviteState.inviteList.size).isEqualTo(2) + assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + assertThat(withInviteState.inviteList[0].isNew).isFalse() + assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2) + assertThat(withInviteState.inviteList[1].isNew).isTrue() + } + } + + private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService { + postInviteRooms( + listOf( + RoomSummary.Filled( + aRoomSummaryDetails( + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + avatarUrl = null, + isDirect = false, + lastMessage = null, + inviter = aRoomMember( + userId = A_USER_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false, + ) + ) + ) + ) + ) + return this + } + + private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService { + postInviteRooms( + listOf( + RoomSummary.Filled( + aRoomSummaryDetails( + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + avatarUrl = null, + isDirect = true, + lastMessage = null, + inviter = aRoomMember( + userId = A_USER_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false, + ) + ) + ) + ) + ) + return this + } + + private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled( + aRoomSummaryDetails( + roomId = id, + name = A_ROOM_NAME, + avatarUrl = null, + isDirect = false, + lastMessage = null, + ) + ) + + private suspend fun TurbineTestContext.awaitInitialItem(): InviteListState { + skipItems(1) + return awaitItem() + } + + private fun createInviteListPresenter( + client: MatrixClient, + seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(), + acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + ) = InviteListPresenter( + client, + seenInvitesStore, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + ) +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt new file mode 100644 index 0000000000..dfb330da59 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.invite.impl.response + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.response.InviteData +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.util.Optional + +class AcceptDeclineInvitePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createAcceptDeclineInvitePresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.invite).isEqualTo(Optional.empty()) + } + } + } + + @Test + fun `present - declining invite cancel flow`() = runTest { + val presenter = createAcceptDeclineInvitePresenter() + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData) + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.of(inviteData)) + assertThat(state.declineAction).isInstanceOf(AsyncAction.Confirming::class.java) + state.eventSink( + InternalAcceptDeclineInviteEvents.CancelDeclineInvite + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.empty()) + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + } + + @Test + fun `present - declining invite error flow`() = runTest { + val declineInviteFailure = lambdaRecorder { -> + Result.failure(RuntimeException("Failed to leave room")) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + leaveRoomLambda = declineInviteFailure + } + ) + } + val presenter = createAcceptDeclineInvitePresenter(client = client) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData) + ) + } + skipItems(1) + awaitItem().also { state -> + state.eventSink( + InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink( + InternalAcceptDeclineInviteEvents.DismissDeclineError + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.empty()) + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteFailure).isCalledOnce() + } + + @Test + fun `present - declining invite success flow`() = runTest { + val declineInviteSuccess = lambdaRecorder { -> + Result.success(Unit) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + leaveRoomLambda = declineInviteSuccess + } + ) + } + val presenter = createAcceptDeclineInvitePresenter(client = client) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData) + ) + } + skipItems(1) + awaitItem().also { state -> + state.eventSink( + InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteSuccess).isCalledOnce() + } + + @Test + fun `present - accepting invite error flow`() = runTest { + val joinRoomFailure = lambdaRecorder { roomId: RoomId -> + Result.failure(RuntimeException("Failed to join room $roomId")) + } + val client = FakeMatrixClient().apply { + joinRoomLambda = joinRoomFailure + } + val presenter = createAcceptDeclineInvitePresenter(client = client) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(inviteData) + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.of(inviteData)) + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink( + InternalAcceptDeclineInviteEvents.DismissAcceptError + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.empty()) + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(joinRoomFailure).isCalledOnce() + } + + @Test + fun `present - accepting invite success flow`() = runTest { + val joinRoomSuccess = lambdaRecorder { roomId: RoomId -> + Result.success(roomId) + } + val client = FakeMatrixClient().apply { + joinRoomLambda = joinRoomSuccess + } + val presenter = createAcceptDeclineInvitePresenter(client = client) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(inviteData) + ) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.invite).isEqualTo(Optional.of(inviteData)) + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Success::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(joinRoomSuccess).isCalledOnce() + } + + private fun anInviteData( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDirect: Boolean = false + ): InviteData { + return InviteData( + roomId = roomId, + roomName = name, + isDirect = isDirect + ) + } + + private fun createAcceptDeclineInvitePresenter( + client: MatrixClient = FakeMatrixClient(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager(), + ): AcceptDeclineInvitePresenter { + return AcceptDeclineInvitePresenter( + client = client, + analyticsService = analyticsService, + notificationDrawerManager = notificationDrawerManager, + ) + } +} diff --git a/features/invitelist/test/build.gradle.kts b/features/invite/test/build.gradle.kts similarity index 88% rename from features/invitelist/test/build.gradle.kts rename to features/invite/test/build.gradle.kts index ce9b0dabe4..44d9d3030c 100644 --- a/features/invitelist/test/build.gradle.kts +++ b/features/invite/test/build.gradle.kts @@ -19,11 +19,11 @@ plugins { } android { - namespace = "io.element.android.features.invitelist.test" + namespace = "io.element.android.features.invite.test" } dependencies { implementation(libs.coroutines.core) implementation(projects.libraries.matrix.api) - api(projects.features.invitelist.api) + api(projects.features.invite.api) } diff --git a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt similarity index 90% rename from features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt rename to features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt index 94b232b2ac..f2a21ac768 100644 --- a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/FakeSeenInvitesStore.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package io.element.android.features.invitelist.test +package io.element.android.features.invite.test -import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt deleted file mode 100644 index fa0cf663a2..0000000000 --- a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt +++ /dev/null @@ -1,505 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.invitelist.impl - -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.TurbineTestContext -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.features.invitelist.api.SeenInvitesStore -import io.element.android.features.invitelist.test.FakeSeenInvitesStore -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomMembershipState -import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import io.element.android.libraries.matrix.test.AN_AVATAR_URL -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_NAME -import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeMatrixRoom -import io.element.android.libraries.matrix.test.room.aRoomMember -import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails -import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService -import io.element.android.libraries.push.api.notifications.NotificationDrawerManager -import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class InviteListPresenterTests { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - starts empty, adds invites when received`() = runTest { - val roomListService = FakeRoomListService() - val presenter = createPresenter( - FakeMatrixClient(roomListService = roomListService) - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.inviteList).isEmpty() - - roomListService.postInviteRooms(listOf(aRoomSummary())) - - val withInviteState = awaitItem() - assertThat(withInviteState.inviteList.size).isEqualTo(1) - assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) - assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) - } - } - - @Test - fun `present - uses user ID and avatar for direct invites`() = runTest { - val roomListService = FakeRoomListService().withDirectChatInvitation() - val presenter = createPresenter( - FakeMatrixClient(roomListService = roomListService) - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val withInviteState = awaitInitialItem() - assertThat(withInviteState.inviteList.size).isEqualTo(1) - assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) - assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value) - assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) - assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo( - AvatarData( - id = A_USER_ID.value, - name = A_USER_NAME, - url = AN_AVATAR_URL, - size = AvatarSize.RoomInviteItem, - ) - ) - assertThat(withInviteState.inviteList[0].sender).isNull() - } - } - - @Test - fun `present - includes sender details for room invites`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val presenter = createPresenter( - FakeMatrixClient(roomListService = roomListService) - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val withInviteState = awaitInitialItem() - assertThat(withInviteState.inviteList.size).isEqualTo(1) - assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME) - assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID) - assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo( - AvatarData( - id = A_USER_ID.value, - name = A_USER_NAME, - url = AN_AVATAR_URL, - size = AvatarSize.InviteSender, - ) - ) - } - } - - @Test - fun `present - shows confirm dialog for declining direct chat invites`() = runTest { - val roomListService = FakeRoomListService().withDirectChatInvitation() - val presenter = InviteListPresenter( - FakeMatrixClient( - roomListService = roomListService, - ), - FakeSeenInvitesStore(), - FakeAnalyticsService(), - FakeNotificationDrawerManager() - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - val newState = awaitItem() - assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java) - - val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible - assertThat(confirmDialog.isDirect).isTrue() - assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME) - } - } - - @Test - fun `present - shows confirm dialog for declining room invites`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val presenter = createPresenter( - FakeMatrixClient(roomListService = roomListService) - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - val newState = awaitItem() - assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java) - - val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible - assertThat(confirmDialog.isDirect).isFalse() - assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME) - } - } - - @Test - fun `present - hides confirm dialog when cancelling`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val presenter = createPresenter( - FakeMatrixClient(roomListService = roomListService) - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - skipItems(1) - - originalState.eventSink(InviteListEvents.CancelDeclineInvite) - - val newState = awaitItem() - assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Hidden::class.java) - } - } - - @Test - fun `present - declines invite after confirming`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val fakeNotificationDrawerManager = FakeNotificationDrawerManager() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - skipItems(1) - - originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) - - skipItems(2) - - assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1) - } - } - - @Test - fun `present - declines invite after confirming and sets state on error`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client) - val ex = Throwable("Ruh roh!") - room.givenLeaveRoomError(ex) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - skipItems(1) - - originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) - - skipItems(1) - - val newState = awaitItem() - - assertThat(newState.declinedAction).isEqualTo(AsyncData.Failure(ex)) - } - } - - @Test - fun `present - dismisses declining error state`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client) - val ex = Throwable("Ruh roh!") - room.givenLeaveRoomError(ex) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) - - skipItems(1) - - originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) - - skipItems(2) - - originalState.eventSink(InviteListEvents.DismissDeclineError) - - val newState = awaitItem() - - assertThat(newState.declinedAction).isEqualTo(AsyncData.Uninitialized) - } - } - - @Test - fun `present - accepts invites and sets state on success`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val fakeNotificationDrawerManager = FakeNotificationDrawerManager() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) - - val newState = awaitItem() - - assertThat(newState.acceptedAction).isEqualTo(AsyncData.Success(A_ROOM_ID)) - assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1) - } - } - - @Test - fun `present - accepts invites and sets state on error`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client) - val ex = Throwable("Ruh roh!") - room.givenJoinRoomResult(Result.failure(ex)) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) - - assertThat(awaitItem().acceptedAction).isEqualTo(AsyncData.Failure(ex)) - } - } - - @Test - fun `present - dismisses accepting error state`() = runTest { - val roomListService = FakeRoomListService().withRoomInvitation() - val client = FakeMatrixClient( - roomListService = roomListService, - ) - val room = FakeMatrixRoom() - val presenter = createPresenter(client) - val ex = Throwable("Ruh roh!") - room.givenJoinRoomResult(Result.failure(ex)) - client.givenGetRoomResult(A_ROOM_ID, room) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val originalState = awaitInitialItem() - originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) - - skipItems(1) - - originalState.eventSink(InviteListEvents.DismissAcceptError) - - val newState = awaitItem() - assertThat(newState.acceptedAction).isEqualTo(AsyncData.Uninitialized) - } - } - - @Test - fun `present - stores seen invites when received`() = runTest { - val roomListService = FakeRoomListService() - val store = FakeSeenInvitesStore() - val presenter = InviteListPresenter( - FakeMatrixClient( - roomListService = roomListService, - ), - store, - FakeAnalyticsService(), - FakeNotificationDrawerManager() - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() - - // When one invite is received, that ID is saved - roomListService.postInviteRooms(listOf(aRoomSummary())) - - awaitItem() - assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID)) - - // When a second is added, both are saved - roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) - - awaitItem() - assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2)) - - // When they're both dismissed, an empty set is saved - roomListService.postInviteRooms(listOf()) - - awaitItem() - assertThat(store.getProvidedRoomIds()).isEmpty() - } - } - - @Test - fun `present - marks invite as new if they're unseen`() = runTest { - val roomListService = FakeRoomListService() - val store = FakeSeenInvitesStore() - store.publishRoomIds(setOf(A_ROOM_ID)) - val presenter = InviteListPresenter( - FakeMatrixClient( - roomListService = roomListService, - ), - store, - FakeAnalyticsService(), - FakeNotificationDrawerManager() - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() - - roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) - skipItems(1) - - val withInviteState = awaitItem() - assertThat(withInviteState.inviteList.size).isEqualTo(2) - assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) - assertThat(withInviteState.inviteList[0].isNew).isFalse() - assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2) - assertThat(withInviteState.inviteList[1].isNew).isTrue() - } - } - - private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService { - postInviteRooms( - listOf( - RoomSummary.Filled( - aRoomSummaryDetails( - roomId = A_ROOM_ID, - name = A_ROOM_NAME, - avatarUrl = null, - isDirect = false, - lastMessage = null, - inviter = aRoomMember( - userId = A_USER_ID, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL, - membership = RoomMembershipState.JOIN, - isNameAmbiguous = false, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, - ) - ) - ) - ) - ) - return this - } - - private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService { - postInviteRooms( - listOf( - RoomSummary.Filled( - aRoomSummaryDetails( - roomId = A_ROOM_ID, - name = A_ROOM_NAME, - avatarUrl = null, - isDirect = true, - lastMessage = null, - inviter = aRoomMember( - userId = A_USER_ID, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL, - membership = RoomMembershipState.JOIN, - isNameAmbiguous = false, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, - ) - ) - ) - ) - ) - return this - } - - private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled( - aRoomSummaryDetails( - roomId = id, - name = A_ROOM_NAME, - avatarUrl = null, - isDirect = false, - lastMessage = null, - ) - ) - - private suspend fun TurbineTestContext.awaitInitialItem(): InviteListState { - skipItems(1) - return awaitItem() - } - - private fun createPresenter( - client: MatrixClient, - seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(), - fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(), - notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager() - ) = InviteListPresenter( - client, - seenInvitesStore, - fakeAnalyticsService, - notificationDrawerManager - ) -} diff --git a/features/joinroom/api/build.gradle.kts b/features/joinroom/api/build.gradle.kts new file mode 100644 index 0000000000..a016c2d195 --- /dev/null +++ b/features/joinroom/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.joinroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.features.roomdirectory.api) +} diff --git a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt new file mode 100644 index 0000000000..60f49b9d36 --- /dev/null +++ b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +interface JoinRoomEntryPoint : FeatureEntryPoint { + fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node + + data class Inputs( + val roomId: RoomId, + val roomDescription: Optional, + ) : NodeInputs +} diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts new file mode 100644 index 0000000000..cfdddcfee8 --- /dev/null +++ b/features/joinroom/impl/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.joinroom.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.joinroom.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.features.invite.api) + implementation(projects.features.roomdirectory.api) + implementation(projects.libraries.uiStrings) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.invite.test) + testImplementation(projects.tests.testutils) + + ksp(libs.showkase.processor) +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt new file mode 100644 index 0000000000..05db8f1cb1 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultJoinRoomEntryPoint @Inject constructor() : JoinRoomEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: JoinRoomEntryPoint.Inputs): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(inputs) + ) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt new file mode 100644 index 0000000000..999030cd50 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +sealed interface JoinRoomEvents { + data object JoinRoom : JoinRoomEvents + data object AcceptInvite : JoinRoomEvents + data object DeclineInvite : JoinRoomEvents +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt new file mode 100644 index 0000000000..eaa195d88d --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.invite.api.response.AcceptDeclineInviteView +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class JoinRoomNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: JoinRoomPresenter.Factory, + private val acceptDeclineInviteView: AcceptDeclineInviteView, +) : Node(buildContext, plugins = plugins) { + private val inputs: JoinRoomEntryPoint.Inputs = inputs() + private val presenter = presenterFactory.create(inputs.roomId, inputs.roomDescription) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + JoinRoomView( + state = state, + onBackPressed = ::navigateUp, + modifier = modifier + ) + acceptDeclineInviteView.Render( + state = state.acceptDeclineInviteState, + onInviteAccepted = {}, + onInviteDeclined = { navigateUp() }, + modifier = Modifier + ) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt new file mode 100644 index 0000000000..5b3225575f --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.InviteData +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.MatrixRoomInfo +import org.jetbrains.annotations.VisibleForTesting +import java.util.Optional + +class JoinRoomPresenter @AssistedInject constructor( + @Assisted private val roomId: RoomId, + @Assisted private val roomDescription: Optional, + private val matrixClient: MatrixClient, + private val acceptDeclineInvitePresenter: Presenter, +) : Presenter { + interface Factory { + fun create(roomId: RoomId, roomDescription: Optional): JoinRoomPresenter + } + + @Composable + override fun present(): JoinRoomState { + val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty()) + val contentState by produceState(initialValue = ContentState.Loading(roomId), key1 = roomInfo) { + value = when { + roomInfo.isPresent -> { + roomInfo.get().toContentState() + } + roomDescription.isPresent -> { + roomDescription.get().toContentState() + } + else -> { + ContentState.Loading(roomId) + } + } + } + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + + fun handleEvents(event: JoinRoomEvents) { + when (event) { + JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> { + val inviteData = contentState.toInviteData() ?: return + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(inviteData) + ) + } + JoinRoomEvents.DeclineInvite -> { + val inviteData = contentState.toInviteData() ?: return + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData) + ) + } + } + } + + return JoinRoomState( + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + eventSink = ::handleEvents + ) + } +} + +@VisibleForTesting +internal fun RoomDescription.toContentState(): ContentState { + return ContentState.Loaded( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + numberOfMembers = numberOfMembers, + isDirect = false, + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = when (joinRule) { + RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock + RoomDescription.JoinRule.PUBLIC -> JoinAuthorisationStatus.CanJoin + else -> JoinAuthorisationStatus.Unknown + } + ) +} + +@VisibleForTesting +internal fun MatrixRoomInfo.toContentState(): ContentState { + return ContentState.Loaded( + roomId = RoomId(id), + name = name, + topic = topic, + alias = canonicalAlias, + numberOfMembers = activeMembersCount, + isDirect = isDirect, + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = when { + currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited + isPublic -> JoinAuthorisationStatus.CanJoin + else -> JoinAuthorisationStatus.Unknown + } + ) +} + +@VisibleForTesting +internal fun ContentState.toInviteData(): InviteData? { + return when (this) { + is ContentState.Loaded -> InviteData( + roomId = roomId, + roomName = computedTitle, + isDirect = isDirect + ) + else -> null + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt new file mode 100644 index 0000000000..08591c068e --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId + +@Immutable +data class JoinRoomState( + val contentState: ContentState, + val acceptDeclineInviteState: AcceptDeclineInviteState, + val eventSink: (JoinRoomEvents) -> Unit +) { + val joinAuthorisationStatus = when (contentState) { + is ContentState.Loaded -> contentState.joinAuthorisationStatus + else -> JoinAuthorisationStatus.Unknown + } +} + +sealed interface ContentState { + data class Loading(val roomId: RoomId) : ContentState + data class UnknownRoom(val roomId: RoomId) : ContentState + data class Loaded( + val roomId: RoomId, + val name: String?, + val topic: String?, + val alias: String?, + val numberOfMembers: Long?, + val isDirect: Boolean, + val roomAvatarUrl: String?, + val joinAuthorisationStatus: JoinAuthorisationStatus, + ) : ContentState { + val computedTitle = name ?: roomId.value + + val computedSubtitle = when { + alias != null -> alias + name == null -> "" + else -> roomId.value + } + + val showMemberCount = numberOfMembers != null + + fun avatarData(size: AvatarSize): AvatarData { + return AvatarData( + id = roomId.value, + name = name, + url = roomAvatarUrl, + size = size, + ) + } + } +} + +enum class JoinAuthorisationStatus { + IsInvited, + CanKnock, + CanJoin, + Unknown, +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt new file mode 100644 index 0000000000..82b81d8e7b --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.anAcceptDeclineInviteState +import io.element.android.libraries.matrix.api.core.RoomId + +open class JoinRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aJoinRoomState( + contentState = aLoadingContentState() + ), + aJoinRoomState( + contentState = anUnknownContentState() + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited) + ), + ) +} + +fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId) + +fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId) + +fun aLoadedContentState( + roomId: RoomId = A_ROOM_ID, + name: String = "Element X android", + alias: String? = "#exa:matrix.org", + topic: String? = "Element X is a secure, private and decentralized messenger.", + numberOfMembers: Long? = null, + isDirect: Boolean = false, + roomAvatarUrl: String? = null, + joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown +) = ContentState.Loaded( + roomId = roomId, + name = name, + alias = alias, + topic = topic, + numberOfMembers = numberOfMembers, + isDirect = isDirect, + roomAvatarUrl = roomAvatarUrl, + joinAuthorisationStatus = joinAuthorisationStatus +) + +fun aJoinRoomState( + contentState: ContentState = aLoadedContentState(), + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + eventSink: (JoinRoomEvents) -> Unit = {} +) = JoinRoomState( + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + eventSink = eventSink +) + +private val A_ROOM_ID = RoomId("!exa:matrix.org") diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt new file mode 100644 index 0000000000..31f065c0e5 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +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.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun JoinRoomView( + state: JoinRoomState, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + HeaderFooterPage( + modifier = modifier, + paddingValues = PaddingValues(16.dp), + topBar = { + JoinRoomTopBar(onBackClicked = onBackPressed) + }, + content = { + JoinRoomContent(contentState = state.contentState) + }, + footer = { + JoinRoomFooter( + joinAuthorisationStatus = state.joinAuthorisationStatus, + onAcceptInvite = { + state.eventSink(JoinRoomEvents.AcceptInvite) + }, + onDeclineInvite = { + state.eventSink(JoinRoomEvents.DeclineInvite) + }, + onJoinRoom = { + state.eventSink(JoinRoomEvents.JoinRoom) + }, + ) + } + ) +} + +@Composable +private fun JoinRoomFooter( + joinAuthorisationStatus: JoinAuthorisationStatus, + onAcceptInvite: () -> Unit, + onDeclineInvite: () -> Unit, + onJoinRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + when (joinAuthorisationStatus) { + JoinAuthorisationStatus.IsInvited -> { + ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { + OutlinedButton( + text = stringResource(CommonStrings.action_decline), + onClick = onDeclineInvite, + modifier = Modifier.weight(1f), + size = ButtonSize.Medium, + ) + Button( + text = stringResource(CommonStrings.action_accept), + onClick = onAcceptInvite, + modifier = Modifier.weight(1f), + size = ButtonSize.Medium, + ) + } + } + JoinAuthorisationStatus.CanJoin -> { + Button( + text = stringResource(R.string.screen_join_room_join_action), + onClick = onJoinRoom, + modifier = modifier.fillMaxWidth(), + size = ButtonSize.Medium, + ) + } + JoinAuthorisationStatus.CanKnock -> { + Button( + text = stringResource(R.string.screen_join_room_knock_action), + onClick = onJoinRoom, + modifier = modifier.fillMaxWidth(), + size = ButtonSize.Medium, + ) + } + JoinAuthorisationStatus.Unknown -> Unit + } +} + +@Composable +private fun JoinRoomContent( + contentState: ContentState, + modifier: Modifier = Modifier, +) { + when (contentState) { + is ContentState.Loaded -> { + ContentScaffold( + modifier = modifier, + avatar = { + Avatar(contentState.avatarData(AvatarSize.RoomHeader)) + }, + title = { + Title(contentState.computedTitle) + }, + subtitle = { + Subtitle(contentState.computedSubtitle) + }, + description = { + Description(contentState.topic ?: "") + }, + memberCount = { + if (contentState.showMemberCount) { + MembersCount(memberCount = contentState.numberOfMembers ?: 0) + } + } + ) + } + is ContentState.UnknownRoom -> { + ContentScaffold( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) + }, + title = { + Title(stringResource(R.string.screen_join_room_title_no_preview)) + }, + subtitle = { + Subtitle(stringResource(R.string.screen_join_room_subtitle_no_preview)) + }, + ) + } + is ContentState.Loading -> { + ContentScaffold( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) + }, + title = { + PlaceholderAtom(width = 200.dp, height = 22.dp) + }, + subtitle = { + PlaceholderAtom(width = 140.dp, height = 20.dp) + }, + ) + } + } +} + +@Composable +private fun ContentScaffold( + avatar: @Composable () -> Unit, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, + modifier: Modifier = Modifier, + description: @Composable (() -> Unit)? = null, + memberCount: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + avatar() + Spacer(modifier = Modifier.height(16.dp)) + title() + Spacer(modifier = Modifier.height(8.dp)) + subtitle() + Spacer(modifier = Modifier.height(8.dp)) + if (memberCount != null) { + memberCount() + } + Spacer(modifier = Modifier.height(8.dp)) + if (description != null) { + description() + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun Title(title: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = title, + style = ElementTheme.typography.fontHeadingMdBold, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + ) +} + +@Composable +private fun Subtitle(subtitle: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = subtitle, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textSecondary, + ) +} + +@Composable +private fun Description(description: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = description, + style = ElementTheme.typography.fontBodySmRegular, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textSecondary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun MembersCount(memberCount: Long) { + Row( + modifier = Modifier + .background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape) + .widthIn(min = 48.dp) + .padding(all = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = CompoundIcons.UserProfile(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Text( + text = "$memberCount", + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun JoinRoomTopBar( + onBackClicked: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClicked) + }, + title = {}, + ) +} + +@PreviewLightDark +@Composable +internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { + JoinRoomView( + state = state, + onBackPressed = { } + ) +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt new file mode 100644 index 0000000000..6c1dfd491d --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.joinroom.impl.JoinRoomPresenter +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import java.util.Optional + +@Module +@ContributesTo(SessionScope::class) +object JoinRoomModule { + @Provides + fun providesJoinRoomPresenterFactory( + client: MatrixClient, + acceptDeclineInvitePresenter: Presenter, + ): JoinRoomPresenter.Factory { + return object : JoinRoomPresenter.Factory { + override fun create(roomId: RoomId, roomDescription: Optional): JoinRoomPresenter { + return JoinRoomPresenter( + roomId = roomId, + roomDescription = roomDescription, + matrixClient = client, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + ) + } + } + } +} diff --git a/features/joinroom/impl/src/main/res/values/localazy.xml b/features/joinroom/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..1c187d403d --- /dev/null +++ b/features/joinroom/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ + + + "Join room" + "Knock to join" + "Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved." + "You must be a member of this room to view the message history." + "Want to join this room?" + "Preview is not available" + diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt new file mode 100644 index 0000000000..e61dd18aad --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.joinroom.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.response.AcceptDeclineInviteState +import io.element.android.features.invite.api.response.anAcceptDeclineInviteState +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +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.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.util.Optional + +class JoinRoomPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createJoinRoomPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID)) + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) + } + } + } + + @Test + fun `present - when room is joined then content state is filled with his data`() = runTest { + val roomInfo = aRoomInfo() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + val contentState = state.contentState as ContentState.Loaded + assertThat(contentState.roomId).isEqualTo(A_ROOM_ID) + assertThat(contentState.name).isEqualTo(roomInfo.name) + assertThat(contentState.topic).isEqualTo(roomInfo.topic) + assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias) + assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.activeMembersCount) + assertThat(contentState.isDirect).isEqualTo(roomInfo.isDirect) + assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl) + } + } + } + + @Test + fun `present - when room is invited then join authorization is equal to invited`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED) + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited) + } + } + } + + @Test + fun `present - when room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest { + val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> } + val acceptDeclinePresenter = Presenter { + anAcceptDeclineInviteState(eventSink = eventSinkRecorder) + } + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED) + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + acceptDeclineInvitePresenter = acceptDeclinePresenter + ) + presenter.test { + skipItems(1) + + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.AcceptInvite) + state.eventSink(JoinRoomEvents.DeclineInvite) + + val inviteData = state.contentState.toInviteData()!! + + assert(eventSinkRecorder) + .isCalledExactly(2) + .withSequence( + listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))), + listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))), + ) + } + } + } + + @Test + fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, isPublic = true) + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanJoin) + } + } + } + + @Test + fun `present - when room is left and not public then join authorization is equal to unknown`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, isPublic = false) + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + @Test + fun `present - when room description is provided and room is not found then content state is filled with data`() = runTest { + val roomDescription = aRoomDescription() + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + val contentState = state.contentState as ContentState.Loaded + assertThat(contentState.roomId).isEqualTo(A_ROOM_ID) + assertThat(contentState.name).isEqualTo(roomDescription.name) + assertThat(contentState.topic).isEqualTo(roomDescription.topic) + assertThat(contentState.alias).isEqualTo(roomDescription.alias) + assertThat(contentState.numberOfMembers).isEqualTo(roomDescription.numberOfMembers) + assertThat(contentState.isDirect).isFalse() + assertThat(contentState.roomAvatarUrl).isEqualTo(roomDescription.avatarUrl) + } + } + } + + @Test + fun `present - when room description join rule is Knock then join authorization is equal to canKnock`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.KNOCK) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanKnock) + } + } + } + + @Test + fun `present - when room description join rule is Public then join authorization is equal to canJoin`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.PUBLIC) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanJoin) + } + } + } + + @Test + fun `present - when room description join rule is Unknown then join authorization is equal to unknown`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.UNKNOWN) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + private fun createJoinRoomPresenter( + roomId: RoomId = A_ROOM_ID, + roomDescription: Optional = Optional.empty(), + matrixClient: MatrixClient = FakeMatrixClient(), + acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() } + ): JoinRoomPresenter { + return JoinRoomPresenter( + roomId = roomId, + roomDescription = roomDescription, + matrixClient = matrixClient, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter + ) + } + + private fun aRoomDescription( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + topic: String? = "A room about something", + alias: String? = "#alias:matrix.org", + avatarUrl: String? = null, + joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN, + numberOfMembers: Long = 2L + ): RoomDescription { + return RoomDescription( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + avatarUrl = avatarUrl, + joinRule = joinRule, + numberOfMembers = numberOfMembers + ) + } +} diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt index 8bc8157c05..664ca4bd28 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt @@ -164,7 +164,7 @@ class LeaveRoomPresenterImplTest { givenGetRoomResult( roomId = A_ROOM_ID, result = FakeMatrixRoom().apply { - givenLeaveRoomError(RuntimeException("Blimey!")) + this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) } }, ) } @@ -210,7 +210,7 @@ class LeaveRoomPresenterImplTest { givenGetRoomResult( roomId = A_ROOM_ID, result = FakeMatrixRoom().apply { - givenLeaveRoomError(RuntimeException("Blimey!")) + this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) } }, ) } diff --git a/features/roomdirectory/api/build.gradle.kts b/features/roomdirectory/api/build.gradle.kts index 04a813bd0f..03430dca7c 100644 --- a/features/roomdirectory/api/build.gradle.kts +++ b/features/roomdirectory/api/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.element.android-library") + id("kotlin-parcelize") } android { diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt index 5b945b2a7d..a27f413e9b 100644 --- a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt @@ -16,13 +16,52 @@ package io.element.android.features.roomdirectory.api +import android.os.Parcelable +import androidx.compose.runtime.Immutable import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +@Parcelize +@Immutable data class RoomDescription( val roomId: RoomId, - val name: String, - val description: String, - val avatarData: AvatarData, - val canBeJoined: Boolean, -) + val name: String?, + val alias: String?, + val topic: String?, + val avatarUrl: String?, + val joinRule: JoinRule, + val numberOfMembers: Long, +) : Parcelable { + enum class JoinRule { + PUBLIC, + KNOCK, + UNKNOWN + } + + @IgnoredOnParcel + val computedName = name ?: alias ?: roomId.value + + @IgnoredOnParcel + val computedDescription: String + get() { + return when { + topic != null -> topic + name != null && alias != null -> alias + name == null && alias == null -> "" + else -> roomId.value + } + } + + @IgnoredOnParcel + val canJoinOrKnock = joinRule == JoinRule.PUBLIC || joinRule == JoinRule.KNOCK + + fun avatarData(size: AvatarSize) = AvatarData( + id = roomId.value, + name = name, + url = avatarUrl, + size = size, + ) +} diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt index 5a693a4a83..4c90b82543 100644 --- a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt @@ -31,6 +31,7 @@ interface RoomDirectoryEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onOpenRoom(roomId: RoomId) + fun onRoomJoined(roomId: RoomId) + fun onResultClicked(roomDescription: RoomDescription) } } diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 85bb195da3..49638ece40 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.androidutils) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt index dc3581589e..32f9571d44 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt @@ -25,6 +25,7 @@ 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.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId @@ -35,9 +36,15 @@ class RoomDirectoryNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: RoomDirectoryPresenter, ) : Node(buildContext, plugins = plugins) { + private fun onResultClicked(roomDescription: RoomDescription) { + plugins().forEach { + it.onResultClicked(roomDescription) + } + } + private fun onRoomJoined(roomId: RoomId) { plugins().forEach { - it.onOpenRoom(roomId) + it.onRoomJoined(roomId) } } @@ -47,6 +54,7 @@ class RoomDirectoryNode @AssistedInject constructor( RoomDirectoryView( state = state, onRoomJoined = ::onRoomJoined, + onResultClicked = ::onResultClicked, onBackPressed = ::navigateUp, modifier = modifier ) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt index efb6624260..e94271cfb8 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt @@ -19,8 +19,6 @@ package io.element.android.features.roomdirectory.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -70,26 +68,20 @@ fun aRoomDescriptionList(): ImmutableList { RoomDescription( roomId = RoomId("!exa:matrix.org"), name = "Element X Android", - description = "Element X is a secure, private and decentralized messenger.", - avatarData = AvatarData( - id = "!exa:matrix.org", - name = "Element X Android", - url = null, - size = AvatarSize.RoomDirectoryItem - ), - canBeJoined = true, + topic = "Element X is a secure, private and decentralized messenger.", + alias = "#element-x-android:matrix.org", + avatarUrl = null, + joinRule = RoomDescription.JoinRule.PUBLIC, + numberOfMembers = 2765, ), RoomDescription( roomId = RoomId("!exi:matrix.org"), name = "Element X iOS", - description = "Element X is a secure, private and decentralized messenger.", - avatarData = AvatarData( - id = "!exi:matrix.org", - name = "Element X iOS", - url = null, - size = AvatarSize.RoomDirectoryItem - ), - canBeJoined = false, + topic = "Element X is a secure, private and decentralized messenger.", + alias = "#element-x-ios:matrix.org", + avatarUrl = null, + joinRule = RoomDescription.JoinRule.UNKNOWN, + numberOfMembers = 356, ) ) } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt index f6188c3269..d6eeb65d7c 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -51,6 +51,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.impl.R import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -70,12 +71,13 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun RoomDirectoryView( state: RoomDirectoryState, + onResultClicked: (RoomDescription) -> Unit, onRoomJoined: (RoomId) -> Unit, onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { - fun joinRoom(roomId: RoomId) { - state.eventSink(RoomDirectoryEvents.JoinRoom(roomId)) + fun joinRoom(roomDescription: RoomDescription) { + state.eventSink(RoomDirectoryEvents.JoinRoom(roomDescription.roomId)) } Scaffold( @@ -86,10 +88,11 @@ fun RoomDirectoryView( content = { padding -> RoomDirectoryContent( state = state, - onResultClicked = ::joinRoom, + onResultClicked = onResultClicked, + onJoinClicked = ::joinRoom, modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) } ) @@ -128,7 +131,8 @@ private fun RoomDirectoryTopBar( @Composable private fun RoomDirectoryContent( state: RoomDirectoryState, - onResultClicked: (RoomId) -> Unit, + onResultClicked: (RoomDescription) -> Unit, + onJoinClicked: (RoomDescription) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -143,6 +147,7 @@ private fun RoomDirectoryContent( displayLoadMoreIndicator = state.displayLoadMoreIndicator, displayEmptyState = state.displayEmptyState, onResultClicked = onResultClicked, + onJoinClicked = onJoinClicked, onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, ) } @@ -153,7 +158,8 @@ private fun RoomDirectoryRoomList( roomDescriptions: ImmutableList, displayLoadMoreIndicator: Boolean, displayEmptyState: Boolean, - onResultClicked: (RoomId) -> Unit, + onResultClicked: (RoomDescription) -> Unit, + onJoinClicked: (RoomDescription) -> Unit, onReachedLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -161,7 +167,12 @@ private fun RoomDirectoryRoomList( items(roomDescriptions) { roomDescription -> RoomDirectoryRoomRow( roomDescription = roomDescription, - onClick = onResultClicked, + onClick = { + onResultClicked(roomDescription) + }, + onJoinClick = { + onJoinClicked(roomDescription) + }, ) } if (displayEmptyState) { @@ -188,10 +199,10 @@ private fun RoomDirectoryRoomList( @Composable private fun LoadMoreIndicator(modifier: Modifier = Modifier) { Box( - modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(24.dp), + modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(24.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( @@ -256,53 +267,54 @@ private fun SearchTextField( @Composable private fun RoomDirectoryRoomRow( roomDescription: RoomDescription, - onClick: (RoomId) -> Unit, + onClick: () -> Unit, + onJoinClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier - .fillMaxWidth() - .clickable(enabled = roomDescription.canBeJoined) { - onClick(roomDescription.roomId) - } - .padding( - top = 12.dp, - bottom = 12.dp, - start = 16.dp, - ) - .height(IntrinsicSize.Min), + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + top = 12.dp, + bottom = 12.dp, + start = 16.dp, + ) + .height(IntrinsicSize.Min), ) { Avatar( - avatarData = roomDescription.avatarData, + avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem), modifier = Modifier.align(Alignment.CenterVertically) ) Column( modifier = Modifier - .weight(1f) - .padding(start = 16.dp) + .weight(1f) + .padding(start = 16.dp) ) { Text( - text = roomDescription.name, + text = roomDescription.computedName, maxLines = 1, style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textPrimary, overflow = TextOverflow.Ellipsis, ) Text( - text = roomDescription.description, + text = roomDescription.computedDescription, maxLines = 1, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary, overflow = TextOverflow.Ellipsis, ) } - if (roomDescription.canBeJoined) { + if (roomDescription.canJoinOrKnock) { Text( text = stringResource(id = CommonStrings.action_join), color = ElementTheme.colors.textSuccessPrimary, modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 4.dp, end = 12.dp) + .align(Alignment.CenterVertically) + .clickable(onClick = onJoinClick) + .padding(start = 4.dp, end = 12.dp) + .testTag(TestTags.callToAction.value) ) } else { Spacer(modifier = Modifier.width(24.dp)) @@ -315,6 +327,7 @@ private fun RoomDirectoryRoomRow( internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { RoomDirectoryView( state = state, + onResultClicked = {}, onRoomJoined = {}, onBackPressed = {}, ) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt index be36eb5053..9038a1a0d6 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt @@ -17,37 +17,20 @@ package io.element.android.features.roomdirectory.impl.root.model import io.element.android.features.roomdirectory.api.RoomDescription -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription fun MatrixRoomDescription.toFeatureModel(): RoomDescription { - fun name(): String { - return name ?: alias ?: roomId.value - } - - fun description(): String { - val topic = topic - val alias = alias - val name = name - return when { - topic != null -> topic - name != null && alias != null -> alias - name == null && alias == null -> "" - else -> roomId.value - } - } - return RoomDescription( roomId = roomId, - name = name(), - description = description(), - avatarData = AvatarData( - id = roomId.value, - name = name, - url = avatarUrl, - size = AvatarSize.RoomDirectoryItem, - ), - canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC, + name = name, + alias = alias, + topic = topic, + avatarUrl = avatarUrl, + numberOfMembers = numberOfMembers, + joinRule = when (joinRule) { + MatrixRoomDescription.JoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC + MatrixRoomDescription.JoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK + MatrixRoomDescription.JoinRule.UNKNOWN -> RoomDescription.JoinRule.UNKNOWN + } ) } diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt index bcac35fc3a..ce949250f0 100644 --- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt @@ -19,11 +19,14 @@ package io.element.android.features.roomdirectory.impl.root import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.testtags.TestTags @@ -55,7 +58,24 @@ class RoomDirectoryViewTest { } @Test - fun `clicking on room item emits the expected Event`() { + fun `clicking on room item then onResultClicked lambda is called once`() { + val eventsRecorder = EventsRecorder() + val state = aRoomDirectoryState( + roomDescriptions = aRoomDescriptionList(), + eventSink = eventsRecorder, + ) + val clickedRoom = state.roomDescriptions.first() + ensureCalledOnceWithParam(clickedRoom) { callback -> + rule.setRoomDirectoryView( + state = state, + onResultClicked = callback, + ) + rule.onNodeWithText(clickedRoom.computedName).performClick() + } + } + + @Test + fun `clicking on room item join cta emits the expected Event`() { val eventsRecorder = EventsRecorder() val state = aRoomDirectoryState( roomDescriptions = aRoomDescriptionList(), @@ -63,7 +83,7 @@ class RoomDirectoryViewTest { ) rule.setRoomDirectoryView(state = state) val clickedRoom = state.roomDescriptions.first() - rule.onNodeWithText(clickedRoom.name).performClick() + rule.onAllNodesWithTag(TestTags.callToAction.value).onFirst().performClick() eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId)) } @@ -100,11 +120,13 @@ class RoomDirectoryViewTest { private fun AndroidComposeTestRule.setRoomDirectoryView( state: RoomDirectoryState, onBackPressed: () -> Unit = EnsureNeverCalled(), + onResultClicked: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { RoomDirectoryView( state = state, + onResultClicked = onResultClicked, onRoomJoined = onRoomJoined, onBackPressed = onBackPressed, ) diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 207be1df5b..ff67ebe42a 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -53,7 +53,7 @@ dependencies { implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink) implementation(projects.libraries.preferences.api) - implementation(projects.features.invitelist.api) + implementation(projects.features.invite.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.leaveroom.api) implementation(projects.services.analytics.api) @@ -75,7 +75,7 @@ dependencies { testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.features.invitelist.test) + testImplementation(projects.features.invite.test) testImplementation(projects.services.analytics.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.tests.testutils) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt index 9057bf23ae..16baf4e41b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.roomlist.impl.InvitesState import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.SessionScope diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt index 2f078d7a69..e2508446e7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -60,7 +60,7 @@ class RoomListDataSource @Inject constructor( fun launchIn(coroutineScope: CoroutineScope) { roomListService .allRooms - .summaries + .filteredSummaries .onEach { roomSummaries -> replaceWith(roomSummaries) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt index 59d8bf9f13..165c92bff8 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchDataSource.kt @@ -45,7 +45,7 @@ class RoomListSearchDataSource @Inject constructor( source = RoomList.Source.All, ) - val roomSummaries: Flow> = roomList.summaries + val roomSummaries: Flow> = roomList.filteredSummaries .map { roomSummaries -> roomSummaries .filterIsInstance() diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt index 802d8ee40f..a1e08cd93b 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt @@ -20,7 +20,7 @@ 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.invitelist.test.FakeSeenInvitesStore +import io.element.android.features.invite.test.FakeSeenInvitesStore import io.element.android.features.roomlist.impl.InvitesState import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt index 9388b54880..f6f5c3cb81 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -29,11 +30,14 @@ import io.element.android.libraries.designsystem.theme.components.TextButton @Composable fun ButtonRowMolecule( modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, + verticalAlignment: Alignment.Vertical = Alignment.Top, content: @Composable RowScope.() -> Unit ) { Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, ) { content() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt index b2cf88b8bc..3cae67cb1b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.designsystem.atomic.pages import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -34,6 +35,7 @@ import io.element.android.libraries.designsystem.theme.components.Text /** * @param modifier Classical modifier. + * @param paddingValues padding values to apply to the content. * @param background optional background component. * @param topBar optional topBar. * @param header optional header. @@ -43,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun HeaderFooterPage( modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(20.dp), background: @Composable () -> Unit = {}, topBar: @Composable () -> Unit = {}, header: @Composable () -> Unit = {}, @@ -57,7 +60,7 @@ fun HeaderFooterPage( background() Column( modifier = Modifier - .padding(all = 20.dp) + .padding(paddingValues = paddingValues) .padding(padding) .consumeWindowInsets(padding) ) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index fa5f083722..24a034c070 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -37,8 +38,10 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import java.io.Closeable +import java.util.Optional interface MatrixClient : Closeable { val sessionId: SessionId @@ -89,6 +92,7 @@ interface MatrixClient : Closeable { suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result fun roomMembershipObserver(): RoomMembershipObserver + fun getRoomInfoFlow(roomId: RoomId): Flow> fun isMe(userId: UserId?) = userId == sessionId diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt index ed6f3fae26..24610a64c9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -28,10 +28,10 @@ class RoomMembershipObserver { val change: MembershipChange, ) - private val _updates = MutableSharedFlow(replay = 1) + private val _updates = MutableSharedFlow(extraBufferCapacity = 10) val updates = _updates.asSharedFlow() - fun notifyUserLeftRoom(roomId: RoomId) { - _updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) + suspend fun notifyUserLeftRoom(roomId: RoomId) { + _updates.emit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt index 8cb43af24c..7bd4c42bdf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.roomlist import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -32,6 +33,8 @@ interface DynamicRoomList : RoomList { val loadedPages: StateFlow val pageSize: Int + val filteredSummaries: SharedFlow> + /** * Load more rooms into the list if possible. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt index 5c526870e5..95992b965e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -17,7 +17,9 @@ package io.element.android.libraries.matrix.api.roomlist import androidx.compose.runtime.Immutable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance /** * Entry point for the room list api. @@ -78,3 +80,7 @@ interface RoomListService { */ val state: StateFlow } + +fun RoomList.loadedStateFlow(): Flow { + return loadingState.filterIsInstance() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index f67548afae..8a3ff31c62 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -79,12 +80,15 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -104,8 +108,10 @@ import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File +import java.util.Optional import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration +import kotlin.time.Duration.Companion.INFINITE import kotlin.time.Duration.Companion.seconds import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset @@ -540,6 +546,22 @@ class RustMatrixClient( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver + override fun getRoomInfoFlow(roomId: RoomId): Flow> { + return flow { + var room = getRoom(roomId) + if (room == null) { + emit(Optional.empty()) + awaitRoom(roomId, INFINITE) + room = getRoom(roomId) + } + room?.use { + room.roomInfoFlow + .map { roomInfo -> Optional.of(roomInfo) } + .collect(this) + } + }.distinctUntilChanged() + } + private suspend fun File.getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt index 4231af07d2..7ac4aed637 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn @@ -92,7 +93,8 @@ internal class RoomListFactory( innerRoomList?.destroy() } return RustDynamicRoomList( - summaries = filteredSummariesFlow, + summaries = summariesFlow, + filteredSummaries = filteredSummariesFlow, loadingState = loadingStateFlow, currentFilter = currentFilter, loadedPages = loadedPages, @@ -105,6 +107,7 @@ internal class RoomListFactory( private class RustDynamicRoomList( override val summaries: MutableSharedFlow>, + override val filteredSummaries: SharedFlow>, override val loadingState: MutableStateFlow, override val currentFilter: MutableStateFlow, override val loadedPages: MutableStateFlow, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt index fbf393e587..b17a2f8cf2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.impl.util +import io.element.android.libraries.core.data.tryOrNull import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow @@ -23,7 +24,9 @@ import org.matrix.rustcomponents.sdk.TaskHandle internal fun mxCallbackFlow(block: suspend ProducerScope.() -> TaskHandle?) = callbackFlow { - val taskHandle: TaskHandle? = block(this) + val taskHandle: TaskHandle? = tryOrNull { + block(this) + } awaitClose { taskHandle?.cancelAndDestroy() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index b485257815..7e86f29cb7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -51,7 +52,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope +import java.util.Optional class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, @@ -94,10 +97,14 @@ class FakeMatrixClient( private var setDisplayNameResult: Result = Result.success(Unit) private var uploadAvatarResult: Result = Result.success(Unit) private var removeAvatarResult: Result = Result.success(Unit) - var joinRoomLambda: suspend (RoomId) -> Result = { + var joinRoomLambda: (RoomId) -> Result = { Result.success(it) } + var getRoomInfoFlowLambda = { _: RoomId -> + flowOf>(Optional.empty()) + } + override suspend fun getRoom(roomId: RoomId): MatrixRoom? { return getRoomResults[roomId] } @@ -267,4 +274,6 @@ class FakeMatrixClient( override suspend fun getRecentlyVisitedRooms(): Result> { return Result.success(visitedRoomsId) } + + override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 11582433d5..0de64514b7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -182,7 +182,7 @@ class FakeMatrixRoom( var removedAvatar: Boolean = false private set - private var leaveRoomError: Throwable? = null + var leaveRoomLambda: (() -> Result) = { Result.success(Unit) } private val _roomInfoFlow: MutableSharedFlow = MutableSharedFlow(replay = 1) override val roomInfoFlow: Flow = _roomInfoFlow @@ -315,8 +315,9 @@ class FakeMatrixRoom( return Result.success(Unit) } - override suspend fun leave(): Result = - leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) + override suspend fun leave(): Result { + return leaveRoomLambda() + } override suspend fun join(): Result { return joinRoomResult @@ -542,10 +543,6 @@ class FakeMatrixRoom( override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result = getWidgetDriverResult - fun givenLeaveRoomError(throwable: Throwable?) { - this.leaveRoomError = throwable - } - fun givenRoomMembersState(state: MatrixRoomMembersState) { membersStateFlow.value = state } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt index 5ff9ed08bf..9d81626bcb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.getAndUpdate @@ -32,6 +33,8 @@ data class SimplePagedRoomList( override val pageSize: Int = Int.MAX_VALUE override val loadedPages = MutableStateFlow(1) + override val filteredSummaries: SharedFlow> = summaries + override suspend fun loadMore() { // No-op loadedPages.getAndUpdate { it + 1 } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 4374d77e52..3046ba3372 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -105,4 +105,9 @@ object TestTags { * Search field. */ val searchTextField = TestTag("search_text_field") + + /** + * Generic call to action. + */ + val callToAction = TestTag("call_to_action") } diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 7a3540e7eb..402d1a48b8 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { implementation(projects.libraries.eventformatter.impl) implementation(projects.libraries.preferences.impl) implementation(projects.libraries.indicator.impl) - implementation(projects.features.invitelist.impl) + implementation(projects.features.invite.impl) implementation(projects.features.roomlist.impl) implementation(projects.features.leaveroom.impl) implementation(projects.features.login.impl) diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 264a754e73..48f629af4d 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -20,7 +20,7 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier -import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore +import io.element.android.features.invite.impl.DefaultSeenInvitesStore import io.element.android.features.leaveroom.impl.LeaveRoomPresenterImpl import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl import io.element.android.features.roomlist.impl.RoomListPresenter diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Day-2_2_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[appnav.room.joined_LoadingRoomNodeView_null_LoadingRoomNodeView-Night-2_3_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_3,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_4,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-1_2_null_4,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Day-0_1_null_4,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_2,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_2,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_3,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_4,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-1_3_null_4,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.components_InviteSummaryRow_null_InviteSummaryRow-Night-0_2_null_4,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f9e386b0a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa90fb393dcb986721190468a83b7b9956e97e2a89d809913ea8895b0f698eba +size 54691 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7885f98840 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb75ce275178fd84581680df4ba8cfe0cc4a5db079160a0a1c22e717521efb56 +size 54607 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8454a07904 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dfe58becc60142b7547331dc4c263e761930dcf0dbb11d4e1efcbfaf618cb31 +size 42625 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8454a07904 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Day-1_2_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dfe58becc60142b7547331dc4c263e761930dcf0dbb11d4e1efcbfaf618cb31 +size 42625 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_0,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_0,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_0,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_1,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_1,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bc150d35ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb2e96e4737a2616676439c4d6ce85279c7ea9f09129058818502658841e8f10 +size 51858 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_3,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_2,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_3,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..958a3d1254 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5fd489d8088e8afd11738f52417cff00ff720ea493ac9b8d350b10c1b1effd0 +size 49458 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a70274e954 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76f3386f41e0b2daad020c08e577e9f7266c8f80e2cb020c6f6d4f51126d182b +size 37982 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a70274e954 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invite.impl.invitelist_InviteListView_null_InviteListView-Night-1_3_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76f3386f41e0b2daad020c08e577e9f7266c8f80e2cb020c6f6d4f51126d182b +size 37982 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index 34611720e6..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fa1bed037eb1bac4ff3e5ce5bf477d646b19c40744e9529f05c331083985be4d -size 54707 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index 506f9344a5..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c42f6c5a1378d921530457716c59dc0b8685d0ce974224a3805f31d2c83a5f58 -size 44419 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index 506f9344a5..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Day-0_1_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c42f6c5a1378d921530457716c59dc0b8685d0ce974224a3805f31d2c83a5f58 -size 44419 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index 89d6f7b185..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:323e331e3be97ce4c20b21d4f72ed4d9528b2b3520201289b5fff4c8c2a95a6e -size 49637 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index fdb5d56575..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b7cdaa24081b0169e704e9e7084cdc263da54e79429dcfde87ea9a324fa1607c -size 39715 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index fdb5d56575..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.invitelist.impl_InviteListView_null_InviteListView-Night-0_2_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b7cdaa24081b0169e704e9e7084cdc263da54e79429dcfde87ea9a324fa1607c -size 39715 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index ac9c773e1a..f7678c840f 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -33,7 +33,7 @@ ] }, { - "name" : ":features:invitelist:impl", + "name" : ":features:invite:impl", "includeRegex" : [ "screen_invites_.*" ] @@ -235,6 +235,12 @@ "includeRegex" : [ "screen_room_directory_.*" ] + }, + { + "name" : ":features:joinroom:impl", + "includeRegex" : [ + "screen_join_room_.*" + ] } ] }