Merge pull request #2695 from element-hq/feature/fga/room_navigation

Room navigation
This commit is contained in:
ganfra 2024-04-15 12:55:29 +02:00 committed by GitHub
commit 1dcdc713a4
133 changed files with 2824 additions and 934 deletions

View file

@ -42,18 +42,20 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode 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.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint 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.FtueService
import io.element.android.features.ftue.api.state.FtueState 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.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint 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.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
@ -82,6 +84,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor( class LoggedInFlowNode @AssistedInject constructor(
@ -213,7 +216,8 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class Room( data class Room(
val roomId: RoomId, val roomId: RoomId,
val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -273,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onRoomSettingsClicked(roomId: RoomId) { 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() { override fun onReportBugClicked() {
@ -290,7 +294,7 @@ class LoggedInFlowNode @AssistedInject constructor(
.build() .build()
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : RoomLoadedFlowNode.Callback { val callback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId))
} }
@ -303,7 +307,11 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) 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<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
is NavTarget.Settings -> { is NavTarget.Settings -> {
@ -317,7 +325,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onOpenRoomNotificationSettings(roomId: RoomId) { 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) val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
@ -349,6 +357,10 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.pop() backstack.pop()
} }
override fun onInviteClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onInviteAccepted(roomId: RoomId) { override fun onInviteAccepted(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId))
} }
@ -370,8 +382,12 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.RoomDirectorySearch -> { NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext) roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback { .callback(object : RoomDirectoryEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onRoomJoined(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } backstack.push(NavTarget.Room(roomId))
}
override fun onResultClicked(roomDescription: RoomDescription) {
backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription))
} }
}) })
.build() .build()

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,15 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room package io.element.android.appnav.room
import android.os.Parcelable 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.features.networkmonitor.api.NetworkStatus 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs 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.di.SessionScope
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.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.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class RoomFlowNode @AssistedInject constructor( class RoomFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, private val client: MatrixClient,
private val networkMonitor: NetworkMonitor, private val roomMembershipObserver: RoomMembershipObserver,
) : private val joinRoomEntryPoint: JoinRoomEntryPoint,
BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages, val roomDescription: Optional<RoomDescription>,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Loading : NavTarget data object Loading : NavTarget
@Parcelize @Parcelize
data object Loaded : NavTarget data object JoinRoom : NavTarget
@Parcelize
data object JoinedRoom : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
loadingRoomStateStateFlow client.getRoomInfoFlow(
.map { inputs.roomId
it is LoadingRoomState.Loaded ).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 -> .launchIn(lifecycleScope)
if (isLoaded) {
backstack.newRoot(NavTarget.Loaded) // When leaving the room from this session only, navigate up.
} else { roomMembershipObserver.updates
backstack.newRoot(NavTarget.Loading) .filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom }
} .onEach {
navigateUp()
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Loaded -> { NavTarget.Loading -> loadingNode(buildContext)
val roomFlowNodeCallback = plugins<RoomLoadedFlowNode.Callback>() NavTarget.JoinRoom -> {
val awaitRoomState = loadingRoomStateStateFlow.value val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription)
if (awaitRoomState is LoadingRoomState.Loaded) { joinRoomEntryPoint.createNode(this, buildContext, inputs)
val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
createNode<RoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} else {
loadingNode(buildContext, this::navigateUp)
}
} }
NavTarget.Loading -> { NavTarget.JoinedRoom -> {
loadingNode(buildContext, this::navigateUp) val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs(inputs.roomId, initialElement = inputs.initialElement)
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
} }
} }
private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> private fun loadingNode(buildContext: BuildContext) = node(buildContext) {
val loadingRoomState by loadingRoomStateStateFlow.collectAsState() Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
val networkStatus by networkMonitor.connectivity.collectAsState() CircularProgressIndicator()
LoadingRoomNodeView( }
state = loadingRoomState,
hasNetworkConnection = networkStatus == NetworkStatus.Online,
modifier = modifier,
onBackClicked = onBackClicked
)
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView( BackstackView(transitionHandler = JumpToEndTransitionHandler())
transitionHandler = JumpToEndTransitionHandler(),
)
} }
} }

View file

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

View file

@ -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<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
private val networkMonitor: NetworkMonitor,
) :
BaseFlowNode<JoinedRoomFlowNode.NavTarget>(
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<JoinedRoomLoadedFlowNode.Callback>()
val awaitRoomState = loadingRoomStateStateFlow.value
if (awaitRoomState is LoadingRoomState.Loaded) {
val inputs = JoinedRoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
createNode<JoinedRoomLoadedFlowNode>(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(),
)
}
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.appnav.room package io.element.android.appnav.room.joined
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -32,6 +32,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory 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.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.BackstackView 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope 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.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class RoomLoadedFlowNode @AssistedInject constructor( class JoinedRoomLoadedFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint, private val messagesEntryPoint: MessagesEntryPoint,
@ -64,10 +61,13 @@ class RoomLoadedFlowNode @AssistedInject constructor(
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver, ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
) : BaseFlowNode<RoomLoadedFlowNode.NavTarget>(
backstack = BackStack( 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, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -81,7 +81,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
data class Inputs( data class Inputs(
val room: MatrixRoom, val room: MatrixRoom,
val initialElement: NavTarget = NavTarget.Messages, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -111,13 +111,6 @@ class RoomLoadedFlowNode @AssistedInject constructor(
appNavigationStateService.onLeavingRoom(id) appNavigationStateService.onLeavingRoom(id)
} }
) )
roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom }
.onEach {
navigateUp()
}
.launchIn(lifecycleScope)
inputs<Inputs>()
} }
private fun trackVisitedRoom() = lifecycleScope.launch { private fun trackVisitedRoom() = lifecycleScope.launch {

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope

View file

@ -27,12 +27,11 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomComponentFactory 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.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@ -41,7 +40,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
class RoomFlowNodeTest { class JoinRoomLoadedFlowNodeTest {
@get:Rule @get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@ -88,18 +87,17 @@ class RoomFlowNodeTest {
} }
} }
private fun aRoomFlowNode( private fun createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>, plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
) = RoomLoadedFlowNode( ) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null), buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins, plugins = plugins,
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope, appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
@ -110,8 +108,8 @@ class RoomFlowNodeTest {
// GIVEN // GIVEN
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = RoomLoadedFlowNode.Inputs(room) val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode( val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,
coroutineScope = this coroutineScope = this
@ -120,9 +118,9 @@ class RoomFlowNodeTest {
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN // THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages) assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages)
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!! val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!!
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
} }
@ -132,8 +130,8 @@ class RoomFlowNodeTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = RoomLoadedFlowNode.Inputs(room) val inputs = JoinedRoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode( val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
@ -143,8 +141,8 @@ class RoomFlowNodeTest {
// WHEN // WHEN
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()
// THEN // THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!! val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
} }
} }

View file

@ -18,6 +18,8 @@ package io.element.android.appnav.room
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.api.roomlist.RoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID

View file

@ -15,11 +15,11 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
namespace = "io.element.android.features.invitelist.api" namespace = "io.element.android.features.invite.api"
} }
dependencies { dependencies {

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -32,7 +32,7 @@ interface InviteListEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onBackClicked() fun onBackClicked()
fun onInviteClicked(roomId: RoomId)
fun onInviteAccepted(roomId: RoomId) fun onInviteAccepted(roomId: RoomId)
} }
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

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

View file

@ -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<InviteData>,
val acceptAction: AsyncAction<RoomId>,
val declineAction: AsyncAction<RoomId>,
val eventSink: (AcceptDeclineInviteEvents) -> Unit,
)

View file

@ -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<AcceptDeclineInviteState> {
override val values: Sequence<AcceptDeclineInviteState>
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<InviteData> = Optional.empty(),
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
invite = invite,
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)

View file

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

View file

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

View file

@ -22,7 +22,7 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.invitelist.impl" namespace = "io.element.android.features.invite.impl"
} }
anvil { anvil {
@ -32,7 +32,7 @@ anvil {
dependencies { dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.invitelist.api) api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
@ -50,7 +50,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -14,13 +14,14 @@
* limitations under the License. * 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.modality.BuildContext
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.Plugin
import com.squareup.anvil.annotations.ContributesBinding 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.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import javax.inject.Inject import javax.inject.Inject

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.invitelist.impl package io.element.android.features.invite.impl
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore 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.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding 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.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Arrangement
import androidx.compose.foundation.layout.Box 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invitelist.impl.R import io.element.android.features.invite.impl.R
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider import io.element.android.features.invite.impl.model.InviteListInviteSummaryProvider
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invite.impl.model.InviteSender
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview

View file

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

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,17 +14,11 @@
* limitations under the License. * 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 { sealed interface InviteListEvents {
data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
data class DeclineInvite(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
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -25,7 +25,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -43,6 +43,10 @@ class InviteListNode @AssistedInject constructor(
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) } plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) }
} }
private fun onInviteClicked(roomId: RoomId) {
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteClicked(roomId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -50,6 +54,8 @@ class InviteListNode @AssistedInject constructor(
state = state, state = state,
onBackClicked = ::onBackClicked, onBackClicked = ::onBackClicked,
onInviteAccepted = ::onInviteAccepted, onInviteAccepted = ::onInviteAccepted,
onInviteDeclined = {},
onInviteClicked = ::onInviteClicked,
) )
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,43 +14,35 @@
* limitations under the License. * 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invitelist.api.SeenInvitesStore import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncData 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.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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class InviteListPresenter @Inject constructor( class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore, private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val notificationDrawerManager: NotificationDrawerManager,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
override fun present(): InviteListState { override fun present(): InviteListState {
@ -75,40 +67,20 @@ class InviteListPresenter @Inject constructor(
) )
} }
val localCoroutineScope = rememberCoroutineScope() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val acceptedAction: MutableState<AsyncData<RoomId>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val declinedAction: MutableState<AsyncData<Unit>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val decliningInvite: MutableState<InviteListInviteSummary?> = remember { mutableStateOf(null) }
fun handleEvent(event: InviteListEvents) { fun handleEvent(event: InviteListEvents) {
when (event) { when (event) {
is InviteListEvents.AcceptInvite -> { is InviteListEvents.AcceptInvite -> {
acceptedAction.value = AsyncData.Uninitialized acceptDeclineInviteState.eventSink(
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData())
)
} }
is InviteListEvents.DeclineInvite -> { is InviteListEvents.DeclineInvite -> {
decliningInvite.value = event.invite acceptDeclineInviteState.eventSink(
} AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData())
)
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
} }
} }
} }
@ -124,38 +96,11 @@ class InviteListPresenter @Inject constructor(
return InviteListState( return InviteListState(
inviteList = inviteList, inviteList = inviteList,
declineConfirmationDialog = decliningInvite.value?.let { acceptDeclineInviteState = acceptDeclineInviteState,
InviteDeclineConfirmationDialog.Visible(
isDirect = it.isDirect,
name = it.roomName,
)
} ?: InviteDeclineConfirmationDialog.Hidden,
acceptedAction = acceptedAction.value,
declinedAction = declinedAction.value,
eventSink = ::handleEvent eventSink = ::handleEvent
) )
} }
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncData<RoomId>>) = 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<AsyncData<Unit>>) = 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 { private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
val i = inviter val i = inviter
val avatarData = if (isDirect && i != null) { 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,
)
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,24 +14,16 @@
* limitations under the License. * 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 androidx.compose.runtime.Immutable
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncData import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class InviteListState( data class InviteListState(
val inviteList: ImmutableList<InviteListInviteSummary>, val inviteList: ImmutableList<InviteListInviteSummary>,
val declineConfirmationDialog: InviteDeclineConfirmationDialog, val acceptDeclineInviteState: AcceptDeclineInviteState,
val acceptedAction: AsyncData<RoomId>,
val declinedAction: AsyncData<Unit>,
val eventSink: (InviteListEvents) -> Unit val eventSink: (InviteListEvents) -> Unit
) )
sealed interface InviteDeclineConfirmationDialog {
data object Hidden : InviteDeclineConfirmationDialog
data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,35 +14,39 @@
* limitations under the License. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
import io.element.android.libraries.architecture.AsyncData 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class InviteListStateProvider : PreviewParameterProvider<InviteListState> { open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider()
override val values: Sequence<InviteListState> override val values: Sequence<InviteListState>
get() = sequenceOf( get() = sequenceOf(
aInviteListState(), anInviteListState(),
aInviteListState().copy(inviteList = persistentListOf()), anInviteListState(inviteList = persistentListOf()),
aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")), ) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState ->
aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")), anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState)
aInviteListState().copy(acceptedAction = AsyncData.Failure(Throwable("Whoops"))), }
aInviteListState().copy(declinedAction = AsyncData.Failure(Throwable("Whoops"))),
)
} }
internal fun aInviteListState() = InviteListState( internal fun anInviteListState(
inviteList = aInviteListInviteSummaryList(), inviteList: ImmutableList<InviteListInviteSummary> = aInviteListInviteSummaryList(),
declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
acceptedAction = AsyncData.Uninitialized, eventSink: (InviteListEvents) -> Unit = {}
declinedAction = AsyncData.Uninitialized, ) = InviteListState(
eventSink = {}, inviteList = inviteList,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
) )
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> { internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,8 +14,9 @@
* limitations under the License. * 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invitelist.impl.components.InviteSummaryRow import io.element.android.features.invite.impl.R
import io.element.android.libraries.architecture.AsyncData 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
@ -56,61 +53,21 @@ fun InviteListView(
state: InviteListState, state: InviteListState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onInviteAccepted: (RoomId) -> Unit, onInviteAccepted: (RoomId) -> Unit,
onInviteDeclined: (RoomId) -> Unit,
onInviteClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.acceptedAction is AsyncData.Success) {
val latestOnInviteAccepted by rememberUpdatedState(onInviteAccepted)
LaunchedEffect(state.acceptedAction) {
latestOnInviteAccepted(state.acceptedAction.data)
}
}
InviteListContent( InviteListContent(
state = state, state = state,
modifier = modifier, modifier = modifier,
onInviteClicked = onInviteClicked,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
AcceptDeclineInviteView(
if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) { state = state.acceptDeclineInviteState,
val contentResource = if (state.declineConfirmationDialog.isDirect) { onInviteAccepted = onInviteAccepted,
R.string.screen_invites_decline_direct_chat_message onInviteDeclined = onInviteDeclined,
} 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) }
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -118,6 +75,7 @@ fun InviteListView(
private fun InviteListContent( private fun InviteListContent(
state: InviteListState, state: InviteListState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onInviteClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -138,8 +96,8 @@ private fun InviteListContent(
content = { padding -> content = { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
if (state.inviteList.isEmpty()) { if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp)) Spacer(Modifier.size(80.dp))
@ -158,6 +116,9 @@ private fun InviteListContent(
items = state.inviteList, items = state.inviteList,
) { index, invite -> ) { index, invite ->
InviteSummaryRow( InviteSummaryRow(
modifier = Modifier.clickable(
onClick = { onInviteClicked(invite.roomId) }
),
invite = invite, invite = invite,
onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) }, onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) }, onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
@ -181,5 +142,7 @@ internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::cl
state = state, state = state,
onBackClicked = {}, onBackClicked = {},
onInviteAccepted = {}, onInviteAccepted = {},
onInviteDeclined = {},
onInviteClicked = {},
) )
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -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<AcceptDeclineInviteState> {
@Composable
override fun present(): AcceptDeclineInviteState {
val localCoroutineScope = rememberCoroutineScope()
val acceptedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val declinedAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var currentInvite by remember {
mutableStateOf<Optional<InviteData>>(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<AsyncAction<RoomId>>) = 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<AsyncAction<RoomId>>) = launch {
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
}
roomId
}.runCatchingUpdatingState(declinedAction)
}
}

View file

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

View file

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

View file

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

View file

@ -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<InviteListState>.awaitInitialItem(): InviteListState {
skipItems(1)
return awaitItem()
}
private fun createInviteListPresenter(
client: MatrixClient,
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
) = InviteListPresenter(
client,
seenInvitesStore,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
)
}

View file

@ -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<InviteData>())
}
}
}
@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<InviteData>())
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
}
@Test
fun `present - declining invite error flow`() = runTest {
val declineInviteFailure = lambdaRecorder { ->
Result.failure<Unit>(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<InviteData>())
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<RoomId>(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<InviteData>())
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,
)
}
}

View file

@ -19,11 +19,11 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.invitelist.test" namespace = "io.element.android.features.invite.test"
} }
dependencies { dependencies {
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.invitelist.api) api(projects.features.invite.api)
} }

View file

@ -14,9 +14,9 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -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<Unit>(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<RoomId>(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<InviteListState>.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
)
}

View file

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

View file

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

View file

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

View file

@ -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<JoinRoomNode>(
buildContext = buildContext,
plugins = listOf(inputs)
)
}
}

View file

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

View file

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

View file

@ -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<RoomDescription>,
private val matrixClient: MatrixClient,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
) : Presenter<JoinRoomState> {
interface Factory {
fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter
}
@Composable
override fun present(): JoinRoomState {
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val contentState by produceState<ContentState>(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
}
}

View file

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

View file

@ -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<JoinRoomState> {
override val values: Sequence<JoinRoomState>
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")

View file

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

View file

@ -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<AcceptDeclineInviteState>,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
roomDescription = roomDescription,
matrixClient = client,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
)
}
}
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>
<string name="screen_join_room_subtitle_no_preview">"You must be a member of this room to view the message history."</string>
<string name="screen_join_room_title_knock">"Want to join this room?"</string>
<string name="screen_join_room_title_no_preview">"Preview is not available"</string>
</resources>

View file

@ -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<RoomDescription> = Optional.empty(),
matrixClient: MatrixClient = FakeMatrixClient(),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = 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
)
}
}

View file

@ -164,7 +164,7 @@ class LeaveRoomPresenterImplTest {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply { result = FakeMatrixRoom().apply {
givenLeaveRoomError(RuntimeException("Blimey!")) this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
}, },
) )
} }
@ -210,7 +210,7 @@ class LeaveRoomPresenterImplTest {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
result = FakeMatrixRoom().apply { result = FakeMatrixRoom().apply {
givenLeaveRoomError(RuntimeException("Blimey!")) this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
}, },
) )
} }

View file

@ -16,6 +16,7 @@
plugins { plugins {
id("io.element.android-library") id("io.element.android-library")
id("kotlin-parcelize")
} }
android { android {

View file

@ -16,13 +16,52 @@
package io.element.android.features.roomdirectory.api 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class RoomDescription( data class RoomDescription(
val roomId: RoomId, val roomId: RoomId,
val name: String, val name: String?,
val description: String, val alias: String?,
val avatarData: AvatarData, val topic: String?,
val canBeJoined: Boolean, 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,
)
}

View file

@ -31,6 +31,7 @@ interface RoomDirectoryEntryPoint : FeatureEntryPoint {
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId) fun onRoomJoined(roomId: RoomId)
fun onResultClicked(roomDescription: RoomDescription)
} }
} }

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)

View file

@ -25,6 +25,7 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -35,9 +36,15 @@ class RoomDirectoryNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomDirectoryPresenter, private val presenter: RoomDirectoryPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onResultClicked(roomDescription: RoomDescription) {
plugins<RoomDirectoryEntryPoint.Callback>().forEach {
it.onResultClicked(roomDescription)
}
}
private fun onRoomJoined(roomId: RoomId) { private fun onRoomJoined(roomId: RoomId) {
plugins<RoomDirectoryEntryPoint.Callback>().forEach { plugins<RoomDirectoryEntryPoint.Callback>().forEach {
it.onOpenRoom(roomId) it.onRoomJoined(roomId)
} }
} }
@ -47,6 +54,7 @@ class RoomDirectoryNode @AssistedInject constructor(
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onRoomJoined = ::onRoomJoined, onRoomJoined = ::onRoomJoined,
onResultClicked = ::onResultClicked,
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
modifier = modifier modifier = modifier
) )

View file

@ -19,8 +19,6 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction 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 io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -70,26 +68,20 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
RoomDescription( RoomDescription(
roomId = RoomId("!exa:matrix.org"), roomId = RoomId("!exa:matrix.org"),
name = "Element X Android", name = "Element X Android",
description = "Element X is a secure, private and decentralized messenger.", topic = "Element X is a secure, private and decentralized messenger.",
avatarData = AvatarData( alias = "#element-x-android:matrix.org",
id = "!exa:matrix.org", avatarUrl = null,
name = "Element X Android", joinRule = RoomDescription.JoinRule.PUBLIC,
url = null, numberOfMembers = 2765,
size = AvatarSize.RoomDirectoryItem
),
canBeJoined = true,
), ),
RoomDescription( RoomDescription(
roomId = RoomId("!exi:matrix.org"), roomId = RoomId("!exi:matrix.org"),
name = "Element X iOS", name = "Element X iOS",
description = "Element X is a secure, private and decentralized messenger.", topic = "Element X is a secure, private and decentralized messenger.",
avatarData = AvatarData( alias = "#element-x-ios:matrix.org",
id = "!exi:matrix.org", avatarUrl = null,
name = "Element X iOS", joinRule = RoomDescription.JoinRule.UNKNOWN,
url = null, numberOfMembers = 356,
size = AvatarSize.RoomDirectoryItem
),
canBeJoined = false,
) )
) )
} }

View file

@ -51,6 +51,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.impl.R import io.element.android.features.roomdirectory.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -70,12 +71,13 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun RoomDirectoryView( fun RoomDirectoryView(
state: RoomDirectoryState, state: RoomDirectoryState,
onResultClicked: (RoomDescription) -> Unit,
onRoomJoined: (RoomId) -> Unit, onRoomJoined: (RoomId) -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun joinRoom(roomId: RoomId) { fun joinRoom(roomDescription: RoomDescription) {
state.eventSink(RoomDirectoryEvents.JoinRoom(roomId)) state.eventSink(RoomDirectoryEvents.JoinRoom(roomDescription.roomId))
} }
Scaffold( Scaffold(
@ -86,10 +88,11 @@ fun RoomDirectoryView(
content = { padding -> content = { padding ->
RoomDirectoryContent( RoomDirectoryContent(
state = state, state = state,
onResultClicked = ::joinRoom, onResultClicked = onResultClicked,
onJoinClicked = ::joinRoom,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) )
} }
) )
@ -128,7 +131,8 @@ private fun RoomDirectoryTopBar(
@Composable @Composable
private fun RoomDirectoryContent( private fun RoomDirectoryContent(
state: RoomDirectoryState, state: RoomDirectoryState,
onResultClicked: (RoomId) -> Unit, onResultClicked: (RoomDescription) -> Unit,
onJoinClicked: (RoomDescription) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -143,6 +147,7 @@ private fun RoomDirectoryContent(
displayLoadMoreIndicator = state.displayLoadMoreIndicator, displayLoadMoreIndicator = state.displayLoadMoreIndicator,
displayEmptyState = state.displayEmptyState, displayEmptyState = state.displayEmptyState,
onResultClicked = onResultClicked, onResultClicked = onResultClicked,
onJoinClicked = onJoinClicked,
onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
) )
} }
@ -153,7 +158,8 @@ private fun RoomDirectoryRoomList(
roomDescriptions: ImmutableList<RoomDescription>, roomDescriptions: ImmutableList<RoomDescription>,
displayLoadMoreIndicator: Boolean, displayLoadMoreIndicator: Boolean,
displayEmptyState: Boolean, displayEmptyState: Boolean,
onResultClicked: (RoomId) -> Unit, onResultClicked: (RoomDescription) -> Unit,
onJoinClicked: (RoomDescription) -> Unit,
onReachedLoadMore: () -> Unit, onReachedLoadMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -161,7 +167,12 @@ private fun RoomDirectoryRoomList(
items(roomDescriptions) { roomDescription -> items(roomDescriptions) { roomDescription ->
RoomDirectoryRoomRow( RoomDirectoryRoomRow(
roomDescription = roomDescription, roomDescription = roomDescription,
onClick = onResultClicked, onClick = {
onResultClicked(roomDescription)
},
onJoinClick = {
onJoinClicked(roomDescription)
},
) )
} }
if (displayEmptyState) { if (displayEmptyState) {
@ -188,10 +199,10 @@ private fun RoomDirectoryRoomList(
@Composable @Composable
private fun LoadMoreIndicator(modifier: Modifier = Modifier) { private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
Box( Box(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.padding(24.dp), .padding(24.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
@ -256,53 +267,54 @@ private fun SearchTextField(
@Composable @Composable
private fun RoomDirectoryRoomRow( private fun RoomDirectoryRoomRow(
roomDescription: RoomDescription, roomDescription: RoomDescription,
onClick: (RoomId) -> Unit, onClick: () -> Unit,
onJoinClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = roomDescription.canBeJoined) { .clickable(onClick = onClick)
onClick(roomDescription.roomId) .padding(
} top = 12.dp,
.padding( bottom = 12.dp,
top = 12.dp, start = 16.dp,
bottom = 12.dp, )
start = 16.dp, .height(IntrinsicSize.Min),
)
.height(IntrinsicSize.Min),
) { ) {
Avatar( Avatar(
avatarData = roomDescription.avatarData, avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem),
modifier = Modifier.align(Alignment.CenterVertically) modifier = Modifier.align(Alignment.CenterVertically)
) )
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = roomDescription.name, text = roomDescription.computedName,
maxLines = 1, maxLines = 1,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = roomDescription.description, text = roomDescription.computedDescription,
maxLines = 1, maxLines = 1,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (roomDescription.canBeJoined) { if (roomDescription.canJoinOrKnock) {
Text( Text(
text = stringResource(id = CommonStrings.action_join), text = stringResource(id = CommonStrings.action_join),
color = ElementTheme.colors.textSuccessPrimary, color = ElementTheme.colors.textSuccessPrimary,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(start = 4.dp, end = 12.dp) .clickable(onClick = onJoinClick)
.padding(start = 4.dp, end = 12.dp)
.testTag(TestTags.callToAction.value)
) )
} else { } else {
Spacer(modifier = Modifier.width(24.dp)) Spacer(modifier = Modifier.width(24.dp))
@ -315,6 +327,7 @@ private fun RoomDirectoryRoomRow(
internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview {
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onResultClicked = {},
onRoomJoined = {}, onRoomJoined = {},
onBackPressed = {}, onBackPressed = {},
) )

View file

@ -17,37 +17,20 @@
package io.element.android.features.roomdirectory.impl.root.model package io.element.android.features.roomdirectory.impl.root.model
import io.element.android.features.roomdirectory.api.RoomDescription 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 import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription
fun MatrixRoomDescription.toFeatureModel(): RoomDescription { 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( return RoomDescription(
roomId = roomId, roomId = roomId,
name = name(), name = name,
description = description(), alias = alias,
avatarData = AvatarData( topic = topic,
id = roomId.value, avatarUrl = avatarUrl,
name = name, numberOfMembers = numberOfMembers,
url = avatarUrl, joinRule = when (joinRule) {
size = AvatarSize.RoomDirectoryItem, MatrixRoomDescription.JoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
), MatrixRoomDescription.JoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC, MatrixRoomDescription.JoinRule.UNKNOWN -> RoomDescription.JoinRule.UNKNOWN
}
) )
} }

View file

@ -19,11 +19,14 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@ -55,7 +58,24 @@ class RoomDirectoryViewTest {
} }
@Test @Test
fun `clicking on room item emits the expected Event`() { fun `clicking on room item then onResultClicked lambda is called once`() {
val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
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<RoomDirectoryEvents>() val eventsRecorder = EventsRecorder<RoomDirectoryEvents>()
val state = aRoomDirectoryState( val state = aRoomDirectoryState(
roomDescriptions = aRoomDescriptionList(), roomDescriptions = aRoomDescriptionList(),
@ -63,7 +83,7 @@ class RoomDirectoryViewTest {
) )
rule.setRoomDirectoryView(state = state) rule.setRoomDirectoryView(state = state)
val clickedRoom = state.roomDescriptions.first() val clickedRoom = state.roomDescriptions.first()
rule.onNodeWithText(clickedRoom.name).performClick() rule.onAllNodesWithTag(TestTags.callToAction.value).onFirst().performClick()
eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId)) eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId))
} }
@ -100,11 +120,13 @@ class RoomDirectoryViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDirectoryView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDirectoryView(
state: RoomDirectoryState, state: RoomDirectoryState,
onBackPressed: () -> Unit = EnsureNeverCalled(), onBackPressed: () -> Unit = EnsureNeverCalled(),
onResultClicked: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(),
onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {
RoomDirectoryView( RoomDirectoryView(
state = state, state = state,
onResultClicked = onResultClicked,
onRoomJoined = onRoomJoined, onRoomJoined = onRoomJoined,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
) )

View file

@ -53,7 +53,7 @@ dependencies {
implementation(projects.libraries.indicator.api) implementation(projects.libraries.indicator.api)
implementation(projects.libraries.deeplink) implementation(projects.libraries.deeplink)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.features.invitelist.api) implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@ -75,7 +75,7 @@ dependencies {
testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -24,7 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding 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.features.roomlist.impl.InvitesState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope

View file

@ -60,7 +60,7 @@ class RoomListDataSource @Inject constructor(
fun launchIn(coroutineScope: CoroutineScope) { fun launchIn(coroutineScope: CoroutineScope) {
roomListService roomListService
.allRooms .allRooms
.summaries .filteredSummaries
.onEach { roomSummaries -> .onEach { roomSummaries ->
replaceWith(roomSummaries) replaceWith(roomSummaries)
} }

View file

@ -45,7 +45,7 @@ class RoomListSearchDataSource @Inject constructor(
source = RoomList.Source.All, source = RoomList.Source.All,
) )
val roomSummaries: Flow<PersistentList<RoomListRoomSummary>> = roomList.summaries val roomSummaries: Flow<PersistentList<RoomListRoomSummary>> = roomList.filteredSummaries
.map { roomSummaries -> .map { roomSummaries ->
roomSummaries roomSummaries
.filterIsInstance<RoomSummary.Filled>() .filterIsInstance<RoomSummary.Filled>()

View file

@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.features.roomlist.impl.InvitesState
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_ID_2

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -29,11 +30,14 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable @Composable
fun ButtonRowMolecule( fun ButtonRowMolecule(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
) { ) {
content() content()
} }

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.designsystem.atomic.pages
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -34,6 +35,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
/** /**
* @param modifier Classical modifier. * @param modifier Classical modifier.
* @param paddingValues padding values to apply to the content.
* @param background optional background component. * @param background optional background component.
* @param topBar optional topBar. * @param topBar optional topBar.
* @param header optional header. * @param header optional header.
@ -43,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun HeaderFooterPage( fun HeaderFooterPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(20.dp),
background: @Composable () -> Unit = {}, background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {}, topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {}, header: @Composable () -> Unit = {},
@ -57,7 +60,7 @@ fun HeaderFooterPage(
background() background()
Column( Column(
modifier = Modifier modifier = Modifier
.padding(all = 20.dp) .padding(paddingValues = paddingValues)
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {

View file

@ -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.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService 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.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.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService 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 io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
import java.util.Optional
interface MatrixClient : Closeable { interface MatrixClient : Closeable {
val sessionId: SessionId val sessionId: SessionId
@ -89,6 +92,7 @@ interface MatrixClient : Closeable {
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver fun roomMembershipObserver(): RoomMembershipObserver
fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>>
fun isMe(userId: UserId?) = userId == sessionId fun isMe(userId: UserId?) = userId == sessionId

View file

@ -28,10 +28,10 @@ class RoomMembershipObserver {
val change: MembershipChange, val change: MembershipChange,
) )
private val _updates = MutableSharedFlow<RoomMembershipUpdate>(replay = 1) private val _updates = MutableSharedFlow<RoomMembershipUpdate>(extraBufferCapacity = 10)
val updates = _updates.asSharedFlow() val updates = _updates.asSharedFlow()
fun notifyUserLeftRoom(roomId: RoomId) { suspend fun notifyUserLeftRoom(roomId: RoomId) {
_updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) _updates.emit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT))
} }
} }

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -32,6 +33,8 @@ interface DynamicRoomList : RoomList {
val loadedPages: StateFlow<Int> val loadedPages: StateFlow<Int>
val pageSize: Int val pageSize: Int
val filteredSummaries: SharedFlow<List<RoomSummary>>
/** /**
* Load more rooms into the list if possible. * Load more rooms into the list if possible.
*/ */

View file

@ -17,7 +17,9 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
/** /**
* Entry point for the room list api. * Entry point for the room list api.
@ -78,3 +80,7 @@ interface RoomListService {
*/ */
val state: StateFlow<State> val state: StateFlow<State>
} }
fun RoomList.loadedStateFlow(): Flow<RoomList.LoadingState.Loaded> {
return loadingState.filterIsInstance()
}

View file

@ -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.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService 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.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.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
@ -79,12 +80,15 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -104,8 +108,10 @@ import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.INFINITE
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
@ -540,6 +546,22 @@ class RustMatrixClient(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>> {
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( private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false, includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) { ): Long = withContext(sessionDispatcher) {

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -92,7 +93,8 @@ internal class RoomListFactory(
innerRoomList?.destroy() innerRoomList?.destroy()
} }
return RustDynamicRoomList( return RustDynamicRoomList(
summaries = filteredSummariesFlow, summaries = summariesFlow,
filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow, loadingState = loadingStateFlow,
currentFilter = currentFilter, currentFilter = currentFilter,
loadedPages = loadedPages, loadedPages = loadedPages,
@ -105,6 +107,7 @@ internal class RoomListFactory(
private class RustDynamicRoomList( private class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>, override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val filteredSummaries: SharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>, override val loadingState: MutableStateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>, override val currentFilter: MutableStateFlow<RoomListFilter>,
override val loadedPages: MutableStateFlow<Int>, override val loadedPages: MutableStateFlow<Int>,

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.util 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.ProducerScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@ -23,7 +24,9 @@ import org.matrix.rustcomponents.sdk.TaskHandle
internal fun <T> mxCallbackFlow(block: suspend ProducerScope<T>.() -> TaskHandle?) = internal fun <T> mxCallbackFlow(block: suspend ProducerScope<T>.() -> TaskHandle?) =
callbackFlow { callbackFlow {
val taskHandle: TaskHandle? = block(this) val taskHandle: TaskHandle? = tryOrNull {
block(this)
}
awaitClose { awaitClose {
taskHandle?.cancelAndDestroy() taskHandle?.cancelAndDestroy()
} }

View file

@ -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.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService 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.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.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
@ -51,7 +52,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import java.util.Optional
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
@ -94,10 +97,14 @@ class FakeMatrixClient(
private var setDisplayNameResult: Result<Unit> = Result.success(Unit) private var setDisplayNameResult: Result<Unit> = Result.success(Unit)
private var uploadAvatarResult: Result<Unit> = Result.success(Unit) private var uploadAvatarResult: Result<Unit> = Result.success(Unit)
private var removeAvatarResult: Result<Unit> = Result.success(Unit) private var removeAvatarResult: Result<Unit> = Result.success(Unit)
var joinRoomLambda: suspend (RoomId) -> Result<RoomId> = { var joinRoomLambda: (RoomId) -> Result<RoomId> = {
Result.success(it) Result.success(it)
} }
var getRoomInfoFlowLambda = { _: RoomId ->
flowOf<Optional<MatrixRoomInfo>>(Optional.empty())
}
override suspend fun getRoom(roomId: RoomId): MatrixRoom? { override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId] return getRoomResults[roomId]
} }
@ -267,4 +274,6 @@ class FakeMatrixClient(
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> { override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> {
return Result.success(visitedRoomsId) return Result.success(visitedRoomsId)
} }
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
} }

View file

@ -182,7 +182,7 @@ class FakeMatrixRoom(
var removedAvatar: Boolean = false var removedAvatar: Boolean = false
private set private set
private var leaveRoomError: Throwable? = null var leaveRoomLambda: (() -> Result<Unit>) = { Result.success(Unit) }
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1) private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
@ -315,8 +315,9 @@ class FakeMatrixRoom(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun leave(): Result<Unit> = override suspend fun leave(): Result<Unit> {
leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) return leaveRoomLambda()
}
override suspend fun join(): Result<Unit> { override suspend fun join(): Result<Unit> {
return joinRoomResult return joinRoomResult
@ -542,10 +543,6 @@ class FakeMatrixRoom(
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
}
fun givenRoomMembersState(state: MatrixRoomMembersState) { fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state membersStateFlow.value = state
} }

View file

@ -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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
@ -32,6 +33,8 @@ data class SimplePagedRoomList(
override val pageSize: Int = Int.MAX_VALUE override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1) override val loadedPages = MutableStateFlow(1)
override val filteredSummaries: SharedFlow<List<RoomSummary>> = summaries
override suspend fun loadMore() { override suspend fun loadMore() {
// No-op // No-op
loadedPages.getAndUpdate { it + 1 } loadedPages.getAndUpdate { it + 1 }

View file

@ -105,4 +105,9 @@ object TestTags {
* Search field. * Search field.
*/ */
val searchTextField = TestTag("search_text_field") val searchTextField = TestTag("search_text_field")
/**
* Generic call to action.
*/
val callToAction = TestTag("call_to_action")
} }

View file

@ -56,7 +56,7 @@ dependencies {
implementation(projects.libraries.eventformatter.impl) implementation(projects.libraries.eventformatter.impl)
implementation(projects.libraries.preferences.impl) implementation(projects.libraries.preferences.impl)
implementation(projects.libraries.indicator.impl) implementation(projects.libraries.indicator.impl)
implementation(projects.features.invitelist.impl) implementation(projects.features.invite.impl)
implementation(projects.features.roomlist.impl) implementation(projects.features.roomlist.impl)
implementation(projects.features.leaveroom.impl) implementation(projects.features.leaveroom.impl)
implementation(projects.features.login.impl) implementation(projects.features.login.impl)

View file

@ -20,7 +20,7 @@ import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier 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.leaveroom.impl.LeaveRoomPresenterImpl
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
import io.element.android.features.roomlist.impl.RoomListPresenter import io.element.android.features.roomlist.impl.RoomListPresenter

Some files were not shown because too many files have changed in this diff Show more