Load JoinedRoom in home screen, pass it to the room flow (#5817)

* Load `JoinedRoom` in `HomeFlowNode.navigateToRoom`, then pass it to the next navigation nodes

* Add delayed loading indicator for cases when loading the room takes too long

* Avoid an extra FFI call in `RustRoomFactory`.

Use `RoomInfo.membership` instead.

Also use `computation` dispatcher, since it should reduce the delay when switching contexts.

* Remove the dispatcher usage when loading the room in `HomeFlowNode`, we immediately call a method that changes the dispatcher used

* Make sure only a single room is opened at a time
This commit is contained in:
Jorge Martin Espinosa 2025-12-02 16:22:55 +01:00 committed by GitHub
parent c292a732d0
commit 77be19bf3b
12 changed files with 180 additions and 53 deletions

View file

@ -39,7 +39,6 @@ import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
@ -84,6 +83,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
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.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
@ -109,6 +109,7 @@ import java.util.UUID
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -265,7 +266,7 @@ class LoggedInFlowNode(
data class Room( data class Room(
val roomIdOrAlias: RoomIdOrAlias, val roomIdOrAlias: RoomIdOrAlias,
val serverNames: List<String> = emptyList(), val serverNames: List<String> = emptyList(),
val trigger: JoinedRoom.Trigger? = null, val trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
val roomDescription: RoomDescription? = null, val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(), val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(),
val targetId: UUID = UUID.randomUUID(), val targetId: UUID = UUID.randomUUID(),
@ -315,8 +316,13 @@ class LoggedInFlowNode(
} }
NavTarget.Home -> { NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback { val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) { override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) backstack.push(
NavTarget.Room(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom)
)
)
} }
override fun navigateToSettings() { override fun navigateToSettings() {
@ -365,7 +371,7 @@ class LoggedInFlowNode(
val target = NavTarget.Room( val target = NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias, roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters, serverNames = data.viaParameters,
trigger = JoinedRoom.Trigger.Timeline, trigger = JoinedRoomAnalyticsEvent.Trigger.Timeline,
initialElement = RoomNavigationTarget.Root(data.eventId), initialElement = RoomNavigationTarget.Root(data.eventId),
) )
if (pushToBackstack) { if (pushToBackstack) {
@ -479,7 +485,7 @@ class LoggedInFlowNode(
NavTarget.Room( NavTarget.Room(
roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(), roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(),
roomDescription = roomDescription, roomDescription = roomDescription,
trigger = JoinedRoom.Trigger.RoomDirectory, trigger = JoinedRoomAnalyticsEvent.Trigger.RoomDirectory,
) )
) )
} }
@ -519,7 +525,7 @@ class LoggedInFlowNode(
suspend fun attachRoom( suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias, roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(), serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger? = null, trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
eventId: EventId? = null, eventId: EventId? = null,
clearBackstack: Boolean, clearBackstack: Boolean,
): RoomFlowNode { ): RoomFlowNode {

View file

@ -19,10 +19,10 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.active
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
@ -60,10 +60,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
import io.element.android.libraries.matrix.api.room.JoinedRoom as JoinedRoomInstance
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -77,7 +80,14 @@ class RoomFlowNode(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = run {
val joinedRoom = (plugins.filterIsInstance<Inputs>().first().initialElement as? RoomNavigationTarget.Root)?.joinedRoom
if (joinedRoom != null) {
NavTarget.JoinedRoom(joinedRoom)
} else {
NavTarget.Loading
}
},
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -87,7 +97,7 @@ class RoomFlowNode(
val roomIdOrAlias: RoomIdOrAlias, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
val serverNames: List<String>, val serverNames: List<String>,
val trigger: Optional<JoinedRoom.Trigger>, val trigger: Optional<JoinedRoomAnalyticsEvent.Trigger>,
val initialElement: RoomNavigationTarget, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
@ -104,11 +114,16 @@ class RoomFlowNode(
data class JoinRoom( data class JoinRoom(
val roomId: RoomId, val roomId: RoomId,
val serverNames: List<String>, val serverNames: List<String>,
val trigger: im.vector.app.features.analytics.plan.JoinedRoom.Trigger, val trigger: JoinedRoomAnalyticsEvent.Trigger,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget data class JoinedRoom(
val roomId: RoomId,
@IgnoredOnParcel val joinedRoom: JoinedRoomInstance? = null,
) : NavTarget {
constructor(joinedRoom: JoinedRoomInstance) : this(joinedRoom.roomId, joinedRoom)
}
} }
override fun onBuilt() { override fun onBuilt() {
@ -133,7 +148,9 @@ class RoomFlowNode(
} }
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) { private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val roomInfoFlow = client.getRoomInfoFlow(roomId) val joinedRoom = (inputs.initialElement as? RoomNavigationTarget.Root)?.joinedRoom
val roomInfoFlow = joinedRoom?.roomInfoFlow?.map { Optional.of(it) }
?: client.getRoomInfoFlow(roomId)
// This observes the local membership changes for the room // This observes the local membership changes for the room
val membershipUpdateFlow = membershipObserver.updates val membershipUpdateFlow = membershipObserver.updates
@ -149,6 +166,11 @@ class RoomFlowNode(
currentMembershipFlow.onEach { (previousMembership, membership) -> currentMembershipFlow.onEach { (previousMembership, membership) ->
Timber.d("Room membership: $membership") Timber.d("Room membership: $membership")
if (membership == CurrentUserMembership.JOINED) { if (membership == CurrentUserMembership.JOINED) {
val currentNavTarget = backstack.active?.key?.navTarget
if (currentNavTarget is NavTarget.JoinedRoom && currentNavTarget.roomId == roomId) {
Timber.d("Already in JoinedRoom $roomId, do nothing")
return@onEach
}
backstack.newRoot(NavTarget.JoinedRoom(roomId)) backstack.newRoot(NavTarget.JoinedRoom(roomId))
} else { } else {
val leavingFromCurrentDevice = val leavingFromCurrentDevice =
@ -163,7 +185,7 @@ class RoomFlowNode(
NavTarget.JoinRoom( NavTarget.JoinRoom(
roomId = roomId, roomId = roomId,
serverNames = serverNames, serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, trigger = inputs.trigger.getOrNull() ?: JoinedRoomAnalyticsEvent.Trigger.Invite,
) )
) )
} }
@ -209,7 +231,8 @@ class RoomFlowNode(
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>() val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs( val inputs = JoinedRoomFlowNode.Inputs(
roomId = navTarget.roomId, roomId = navTarget.roomId,
initialElement = inputs.initialElement initialElement = inputs.initialElement,
joinedRoom = navTarget.joinedRoom,
) )
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }

View file

@ -10,12 +10,15 @@ package io.element.android.appnav.room
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable { sealed interface RoomNavigationTarget : Parcelable {
@Parcelize @Parcelize
data class Root( data class Root(
val eventId: EventId? = null, val eventId: EventId? = null,
@IgnoredOnParcel val joinedRoom: JoinedRoom? = null,
) : RoomNavigationTarget ) : RoomNavigationTarget
@Parcelize @Parcelize

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
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.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -63,11 +64,12 @@ class JoinedRoomFlowNode(
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val joinedRoom: JoinedRoom?,
val initialElement: RoomNavigationTarget, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId) private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId, inputs.joinedRoom)
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize

View file

@ -23,6 +23,19 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class LoadingBaseRoomStateFlowFactoryTest { class LoadingBaseRoomStateFlowFactoryTest {
@Test
fun `flow should emit only Loaded when we already pass a JoinedRoom`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
val matrixClient = FakeMatrixClient(A_SESSION_ID)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = room)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
ensureAllEventsConsumed()
}
}
@Test @Test
fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest { fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)) val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
@ -31,7 +44,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
} }
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
.create(this, A_ROOM_ID) .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
@ -45,7 +58,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
.create(this, A_ROOM_ID) .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room) matrixClient.givenGetRoomResult(A_ROOM_ID, room)
@ -60,7 +73,7 @@ class LoadingBaseRoomStateFlowFactoryTest {
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
.create(this, A_ROOM_ID) .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
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.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint { interface HomeEntryPoint : FeatureEntryPoint {
fun createNode( fun createNode(
@ -22,7 +23,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
): Node ): Node
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId) fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom() fun navigateToCreateRoom()
fun navigateToSettings() fun navigateToSettings()
fun navigateToSetUpRecovery() fun navigateToSetUpRecovery()

View file

@ -14,6 +14,8 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
@ -41,20 +43,33 @@ import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.AsyncData
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.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@ -71,6 +86,7 @@ class HomeFlowNode(
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer, private val leaveRoomRenderer: LeaveRoomRenderer,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<HomeFlowNode.NavTarget>( ) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -150,9 +166,58 @@ class HomeFlowNode(
return node(buildContext) { modifier -> return node(buildContext) { modifier ->
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
val loadingJoinedRoomJob = remember { mutableStateOf<AsyncData<Job>>(AsyncData.Uninitialized) }
if (loadingJoinedRoomJob.value.isLoading()) {
DelayedVisibility(duration = 400.milliseconds) {
ProgressDialog(
onDismissRequest = {
loadingJoinedRoomJob.value.dataOrNull()?.cancel()
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
)
}
}
fun navigateToRoom(
roomId: RoomId,
) {
if (!loadingJoinedRoomJob.value.isUninitialized()) {
Timber.w("Already loading a room, ignoring navigateToRoom for $roomId")
return
}
val job = sessionCoroutineScope.launch {
runCatchingExceptions {
matrixClient.getJoinedRoom(roomId)
}.fold(
onSuccess = { joinedRoom ->
if (isActive) {
callback.navigateToRoom(roomId, joinedRoom)
loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
},
onFailure = {
// If the operation wasn't cancelled, navigate without the room, using the room id
if (it !is CancellationException) {
callback.navigateToRoom(roomId, null)
}
loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job)
// Wait a bit before resetting the state to avoid allowing to open several rooms
delay(200.milliseconds)
loadingJoinedRoomJob.value = AsyncData.Uninitialized
}
)
}
loadingJoinedRoomJob.value = AsyncData.Loading(job)
}
HomeView( HomeView(
homeState = state, homeState = state,
onRoomClick = callback::navigateToRoom, onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings, onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom, onStartChatClick = callback::navigateToCreateRoom,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery, onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@ -165,7 +230,7 @@ class HomeFlowNode(
acceptDeclineInviteView = { acceptDeclineInviteView = {
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState, state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = callback::navigateToRoom, onAcceptInviteSuccess = ::navigateToRoom,
onDeclineInviteSuccess = { }, onDeclineInviteSuccess = { },
modifier = Modifier modifier = Modifier
) )

View file

@ -13,17 +13,19 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
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.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DefaultHomeEntryPointTest { class DefaultHomeEntryPointTest {
@Test @Test
fun `test node builder`() { fun `test node builder`() = runTest {
val entryPoint = DefaultHomeEntryPoint() val entryPoint = DefaultHomeEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
HomeFlowNode( HomeFlowNode(
@ -39,10 +41,11 @@ class DefaultHomeEntryPointTest {
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() },
sessionCoroutineScope = backgroundScope,
) )
} }
val callback = object : HomeEntryPoint.Callback { val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) = lambdaError() override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError() override fun navigateToCreateRoom() = lambdaError()
override fun navigateToSettings() = lambdaError() override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = lambdaError() override fun navigateToSetUpRecovery() = lambdaError()

View file

@ -65,6 +65,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -261,6 +262,7 @@ class TimelinePresenter(
items items
} }
.onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem)
.flowOn(dispatchers.computation)
.launchIn(this) .launchIn(this)
} }

View file

@ -36,6 +36,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.TimelineConfiguration import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus import org.matrix.rustcomponents.sdk.TimelineFocus
@ -60,7 +61,7 @@ class RustRoomFactory(
private val roomInfoMapper: RoomInfoMapper, private val roomInfoMapper: RoomInfoMapper,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) { ) {
private val dispatcher = dispatchers.io.limitedParallelism(1) private val dispatcher = dispatchers.computation.limitedParallelism(1)
private val mutex = Mutex() private val mutex = Mutex()
private val isDestroyed: AtomicBoolean = AtomicBoolean(false) private val isDestroyed: AtomicBoolean = AtomicBoolean(false)
@ -86,24 +87,21 @@ class RustRoomFactory(
return@withContext null return@withContext null
} }
val room = awaitRoomInRoomList(roomId) ?: return@withContext null val room = awaitRoomInRoomList(roomId) ?: return@withContext null
getBaseRoom(room) getBaseRoom(sdkRoom = room, roomInfo = room.roomInfo())
} }
} }
private suspend fun getBaseRoom(sdkRoom: Room): RustBaseRoom { private fun getBaseRoom(sdkRoom: Room, roomInfo: RoomInfo) = RustBaseRoom(
val initialRoomInfo = sdkRoom.roomInfo() sessionId = sessionId,
return RustBaseRoom( deviceId = deviceId,
sessionId = sessionId, innerRoom = sdkRoom,
deviceId = deviceId, coroutineDispatchers = dispatchers,
innerRoom = sdkRoom, roomSyncSubscriber = roomSyncSubscriber,
coroutineDispatchers = dispatchers, roomMembershipObserver = roomMembershipObserver,
roomSyncSubscriber = roomSyncSubscriber, roomInfoMapper = roomInfoMapper,
roomMembershipObserver = roomMembershipObserver, initialRoomInfo = roomInfoMapper.map(roomInfo),
roomInfoMapper = roomInfoMapper, sessionCoroutineScope = sessionCoroutineScope,
initialRoomInfo = roomInfoMapper.map(initialRoomInfo), )
sessionCoroutineScope = sessionCoroutineScope,
)
}
suspend fun getJoinedRoomOrPreview(roomId: RoomId, serverNames: List<String>): GetRoomResult? = withContext(dispatcher) { suspend fun getJoinedRoomOrPreview(roomId: RoomId, serverNames: List<String>): GetRoomResult? = withContext(dispatcher) {
mutex.withLock { mutex.withLock {
@ -113,10 +111,11 @@ class RustRoomFactory(
} }
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null
val roomInfo = sdkRoom.roomInfo()
val parentTransaction = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom) val parentTransaction = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom)
if (sdkRoom.membership() == Membership.JOINED) { if (roomInfo.membership == Membership.JOINED) {
analyticsService.recordTransaction( analyticsService.recordTransaction(
name = "Get joined room", name = "Get joined room",
operation = "RustRoomFactory.getJoinedRoomOrPreview", operation = "RustRoomFactory.getJoinedRoomOrPreview",
@ -140,10 +139,9 @@ class RustRoomFactory(
) )
} }
val baseRoom = transaction.recordChildTransaction(operation = "getBaseRoom", description = "Get room from SDK") { getBaseRoom(sdkRoom) }
GetRoomResult.Joined( GetRoomResult.Joined(
JoinedRustRoom( JoinedRustRoom(
baseRoom = baseRoom, baseRoom = getBaseRoom(sdkRoom, roomInfo),
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
roomContentForwarder = roomContentForwarder, roomContentForwarder = roomContentForwarder,
liveInnerTimeline = timeline, liveInnerTimeline = timeline,
@ -169,7 +167,7 @@ class RustRoomFactory(
GetRoomResult.NotJoined( GetRoomResult.NotJoined(
NotJoinedRustRoom( NotJoinedRustRoom(
sessionId = sessionId, sessionId = sessionId,
localRoom = getBaseRoom(sdkRoom), localRoom = getBaseRoom(sdkRoom, roomInfo),
previewInfo = RoomPreviewInfoMapper.map(preview.info()), previewInfo = RoomPreviewInfoMapper.map(preview.info()),
) )
) )

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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.asFlow import kotlinx.coroutines.flow.asFlow
@ -39,16 +40,21 @@ open class LoadingRoomStateProvider : PreviewParameterProvider<LoadingRoomState>
@Inject @Inject
class LoadingRoomStateFlowFactory(private val matrixClient: MatrixClient) { class LoadingRoomStateFlowFactory(private val matrixClient: MatrixClient) {
fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow<LoadingRoomState> = fun create(lifecycleScope: CoroutineScope, roomId: RoomId, joinedRoom: JoinedRoom?): StateFlow<LoadingRoomState> {
getJoinedRoomFlow(roomId) return if (joinedRoom != null) {
.map { room -> MutableStateFlow<LoadingRoomState>(LoadingRoomState.Loaded(joinedRoom))
if (room != null) { } else {
LoadingRoomState.Loaded(room) getJoinedRoomFlow(roomId)
} else { .map { room ->
LoadingRoomState.Error if (room != null) {
LoadingRoomState.Loaded(room)
} else {
LoadingRoomState.Error
}
} }
} .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading)
.stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading) }
}
private fun getJoinedRoomFlow(roomId: RoomId): Flow<JoinedRoom?> = suspend { private fun getJoinedRoomFlow(roomId: RoomId): Flow<JoinedRoom?> = suspend {
matrixClient.getJoinedRoom(roomId = roomId) matrixClient.getJoinedRoom(roomId = roomId)

View file

@ -20,6 +20,11 @@ android {
namespace = "ui" namespace = "ui"
} }
tasks.withType(Test::class.java) {
// Don't fail the test run if there are no tests, this can happen if we run them with screenshot test disabled
failOnNoDiscoveredTests = false
}
dependencies { dependencies {
// Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13) // Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13)
constraints.add("testImplementation", "com.google.guava:guava") { constraints.add("testImplementation", "com.google.guava:guava") {