Merge branch 'develop' into feature/fga/space_list_join_action

This commit is contained in:
Benoit Marty 2025-10-01 12:41:22 +02:00
commit 084bf5392a
70 changed files with 824 additions and 385 deletions

View file

@ -9,15 +9,15 @@ package io.element.android.x.di
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@Inject @Inject
class DefaultRoomComponentFactory( class DefaultRoomGraphFactory(
private val sessionGraph: SessionGraph, private val sessionGraph: SessionGraph,
) : RoomComponentFactory { ) : RoomGraphFactory {
override fun create(room: JoinedRoom): Any { override fun create(room: JoinedRoom): Any {
return sessionGraph.roomGraphFactory return sessionGraph.roomGraphFactory
.create(room, room) .create(room, room)

View file

@ -7,18 +7,15 @@
package io.element.android.x.di package io.element.android.x.di
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@GraphExtension(RoomScope::class) @GraphExtension(RoomScope::class)
interface RoomGraph : NodeFactoriesBindings { interface RoomGraph : NodeFactoriesBindings {
@ContributesTo(SessionScope::class)
@GraphExtension.Factory @GraphExtension.Factory
interface Factory { interface Factory {
fun create( fun create(

View file

@ -7,8 +7,6 @@
package io.element.android.x.di package io.element.android.x.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.NodeFactoriesBindings
@ -19,7 +17,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
interface SessionGraph : NodeFactoriesBindings { interface SessionGraph : NodeFactoriesBindings {
val roomGraphFactory: RoomGraph.Factory val roomGraphFactory: RoomGraph.Factory
@ContributesTo(AppScope::class)
@GraphExtension.Factory @GraphExtension.Factory
interface Factory { interface Factory {
fun create(@Provides matrixClient: MatrixClient): SessionGraph fun create(@Provides matrixClient: MatrixClient): SessionGraph

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api) implementation(projects.features.login.api)

View file

@ -36,10 +36,8 @@ import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace 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.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.JoinedRoom 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
@ -82,6 +80,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
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
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.ui.common.nodes.emptyNode
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.first import kotlinx.coroutines.flow.first
@ -282,7 +281,7 @@ class LoggedInFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext) NavTarget.Placeholder -> emptyNode(buildContext)
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
val callback = object : LoggedInNode.Callback { val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() { override fun navigateToNotificationTroubleshoot() {
@ -549,13 +548,6 @@ class LoggedInFlowNode(
} }
} }
@ContributesNode(AppScope::class)
@Inject
class PlaceholderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
@Parcelize @Parcelize
private class AttachRoomOperation( private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room, val roomTarget: LoggedInFlowNode.NavTarget.Room,

View file

@ -11,15 +11,11 @@ import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
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.ui.Alignment
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
import com.bumble.appyx.core.node.Node 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.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
@ -51,7 +47,6 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -61,6 +56,7 @@ import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -219,9 +215,10 @@ import timber.log.Timber
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.LoggedInFlow -> { is NavTarget.LoggedInFlow -> {
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId)
Timber.w("Couldn't find any session, go through SplashScreen") ?: return emptyNode(buildContext).also {
} Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInAppScopeFlowNode.Callback { val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
@ -252,7 +249,7 @@ import timber.log.Timber
) )
).build() ).build()
} }
NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.SplashScreen -> emptyNode(buildContext)
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@ -289,12 +286,6 @@ import timber.log.Timber
} }
} }
private fun splashNode(buildContext: BuildContext) = node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
suspend fun handleIntent(intent: Intent) { suspend fun handleIntent(intent: Intent) {
val resolvedIntent = intentResolver.resolve(intent) ?: return val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) { when (resolvedIntent) {

View file

@ -9,6 +9,6 @@ package io.element.android.appnav.di
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
fun interface RoomComponentFactory { fun interface RoomGraphFactory {
fun create(room: JoinedRoom): Any fun create(room: JoinedRoom): Any
} }

View file

@ -20,7 +20,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget 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
@ -56,7 +56,7 @@ class JoinedRoomLoadedFlowNode(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
roomComponentFactory: RoomComponentFactory, roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) { initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) {
@ -83,7 +83,7 @@ class JoinedRoomLoadedFlowNode(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>() private val callbacks = plugins.filterIsInstance<Callback>()
override val graph = roomComponentFactory.create(inputs.room) override val graph = roomGraphFactory.create(inputs.room)
init { init {
lifecycle.subscribe( lifecycle.subscribe(

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule 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.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -70,7 +70,7 @@ class JoinedRoomLoadedFlowNodeTest {
} }
} }
private class FakeRoomComponentFactory : RoomComponentFactory { private class FakeRoomGraphFactory : RoomGraphFactory {
override fun create(room: JoinedRoom): Any { override fun create(room: JoinedRoom): Any {
return Unit return Unit
} }
@ -110,7 +110,7 @@ class JoinedRoomLoadedFlowNodeTest {
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = this, sessionCoroutineScope = this,
roomComponentFactory = FakeRoomComponentFactory(), roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
) )

View file

@ -19,7 +19,7 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -36,7 +36,7 @@ import kotlinx.parcelize.Parcelize
class ChangeRoomMemberRolesRootNode( class ChangeRoomMemberRolesRootNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
roomComponentFactory: RoomComponentFactory, roomGraphFactory: RoomGraphFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>( ) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@ -54,7 +54,7 @@ class ChangeRoomMemberRolesRootNode(
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
override val graph = roomComponentFactory.create(inputs.joinedRoom) override val graph = roomGraphFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return createNode<ChangeRolesNode>( return createNode<ChangeRolesNode>(

View file

@ -26,7 +26,7 @@ class DefaultChangeRoomMemberRolesEntyPointTest {
ChangeRoomMemberRolesRootNode( ChangeRoomMemberRolesRootNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
roomComponentFactory = { }, roomGraphFactory = { },
) )
} }
val room = FakeJoinedRoom() val room = FakeJoinedRoom()

View file

@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)

View file

@ -8,10 +8,7 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Parcelable import android.os.Parcelable
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.ui.Alignment
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
@ -20,10 +17,8 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.Inject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
@ -35,8 +30,8 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
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.createNode import io.element.android.libraries.architecture.createNode
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.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -88,7 +83,7 @@ class FtueFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) emptyNode(buildContext)
} }
is NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
@ -147,17 +142,3 @@ class FtueFlowNode(
BackstackView() BackstackView()
} }
} }
@ContributesNode(AppScope::class)
@Inject
class PlaceholderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)

View file

@ -14,7 +14,6 @@ import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
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.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.newRoot
@ -30,6 +29,7 @@ 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.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -42,7 +42,7 @@ class LockScreenSettingsFlowNode(
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
) : BaseFlowNode<LockScreenSettingsFlowNode.NavTarget>( ) : BaseFlowNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Unknown, initialElement = NavTarget.Loading,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -50,7 +50,7 @@ class LockScreenSettingsFlowNode(
) { ) {
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Unknown : NavTarget data object Loading : NavTarget
@Parcelize @Parcelize
data object Unlock : NavTarget data object Unlock : NavTarget
@ -94,6 +94,9 @@ class LockScreenSettingsFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Loading -> {
emptyNode(buildContext)
}
NavTarget.Unlock -> { NavTarget.Unlock -> {
val callback = object : PinUnlockNode.Callback { val callback = object : PinUnlockNode.Callback {
override fun onUnlock() { override fun onUnlock() {
@ -113,7 +116,6 @@ class LockScreenSettingsFlowNode(
} }
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback)) createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.Unknown -> node(buildContext) { }
} }
} }

View file

@ -459,6 +459,10 @@ class MessagesFlowNode(
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callType)
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
}
} }
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback)) createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
} }

View file

@ -21,6 +21,6 @@ interface MessagesNavigator {
fun onReportContentClick(eventId: EventId, senderId: UserId) fun onReportContentClick(eventId: EventId, senderId: UserId)
fun onEditPollClick(eventId: EventId) fun onEditPollClick(eventId: EventId)
fun onPreviewAttachment(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun onPreviewAttachment(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
} }

View file

@ -92,6 +92,14 @@ class MessagesNode(
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
data class Inputs(
val focusedEventId: EventId?,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val timelineController = TimelineController(room, room.liveTimeline) private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
@ -99,18 +107,12 @@ class MessagesNode(
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create( actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default, postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode() timelineMode = timelineController.mainTimelineMode(),
), ),
timelineController = timelineController, timelineController = timelineController,
) )
private val callbacks = plugins<Callback>()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
private val inputs = inputs<Inputs>()
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClick()
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun onPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun onUserDataClick(userId: UserId) fun onUserDataClick(userId: UserId)
@ -122,9 +124,10 @@ class MessagesNode(
fun onCreatePollClick() fun onCreatePollClick()
fun onEditPollClick(eventId: EventId) fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId) fun onJoinCallClick(roomId: RoomId)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun onRoomDetailsClick()
fun onViewAllPinnedEvents() fun onViewAllPinnedEvents()
fun onViewKnockRequests() fun onViewKnockRequests()
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
} }
override fun onBuilt() { override fun onBuilt() {
@ -143,6 +146,14 @@ class MessagesNode(
callbacks.forEach { it.onRoomDetailsClick() } callbacks.forEach { it.onRoomDetailsClick() }
} }
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
private fun onViewKnockRequestsClick() {
callbacks.forEach { it.onViewKnockRequests() }
}
private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because: // Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false. // - if callbacks is empty, it will return true and we want to return false.
@ -223,11 +234,11 @@ class MessagesNode(
callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) } callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) }
} }
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
if (roomId == room.roomId) { if (roomId == room.roomId) {
displaySameRoomToast() displaySameRoomToast()
} else { } else {
val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), viaParameters = serverNames.toImmutableList()) val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList())
callbacks.forEach { it.onPermalinkClick(permalinkData) } callbacks.forEach { it.onPermalinkClick(permalinkData) }
} }
} }
@ -236,10 +247,6 @@ class MessagesNode(
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) } callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
} }
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
private fun onSendLocationClick() { private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() } callbacks.forEach { it.onSendLocationClick() }
} }
@ -252,10 +259,6 @@ class MessagesNode(
callbacks.forEach { it.onJoinCallClick(room.roomId) } callbacks.forEach { it.onJoinCallClick(room.roomId) }
} }
private fun onViewKnockRequestsClick() {
callbacks.forEach { it.onViewKnockRequests() }
}
private fun displaySameRoomToast() { private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android) context.toast(CommonStrings.screen_room_permalink_same_room_android)
} }
@ -291,7 +294,15 @@ class MessagesNode(
} }
}, },
onUserDataClick = this::onUserDataClick, onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) }, onLinkClick = { url, customTab ->
onLinkClick(
activity = activity,
darkTheme = isDark,
url = url,
eventSink = state.timelineState.eventSink,
customTab = customTab,
)
},
onSendLocationClick = this::onSendLocationClick, onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick, onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick, onJoinCallClick = this::onJoinCallClick,

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.threads package io.element.android.features.messages.impl.threads
import android.app.Activity import android.app.Activity
import android.content.Context
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -44,19 +43,18 @@ import io.element.android.features.messages.impl.timeline.di.TimelineItemPresent
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
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.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.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.CreateTimelineParams
@ -65,9 +63,9 @@ import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -77,7 +75,6 @@ import kotlinx.coroutines.runBlocking
class ThreadedMessagesNode( class ThreadedMessagesNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom, private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@ -125,6 +122,7 @@ class ThreadedMessagesNode(
fun onCreatePollClick() fun onCreatePollClick()
fun onEditPollClick(eventId: EventId) fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId) fun onJoinCallClick(roomId: RoomId)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
} }
override fun onBuilt() { override fun onBuilt() {
@ -191,8 +189,11 @@ class ThreadedMessagesNode(
if (eventId != null) { if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId)) eventSink(TimelineEvents.FocusOnEvent(eventId))
} else { } else {
// Click on the same room, ignore // Click on the same room, navigate up
displaySameRoomToast() // Note that it can not be enough to go back to the room if the thread has been opened
// following a permalink from another thread. In this case navigating up will go back
// to the previous thread. But this should not happen often.
navigateUp()
} }
} else { } else {
callbacks.forEach { it.onPermalinkClick(roomLink) } callbacks.forEach { it.onPermalinkClick(roomLink) }
@ -219,7 +220,14 @@ class ThreadedMessagesNode(
callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) } callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) }
} }
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) = Unit override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList())
callbacks.forEach { it.onPermalinkClick(permalinkData) }
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
}
private fun onSendLocationClick() { private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() } callbacks.forEach { it.onSendLocationClick() }
@ -233,13 +241,6 @@ class ThreadedMessagesNode(
callbacks.forEach { it.onJoinCallClick(room.roomId) } callbacks.forEach { it.onJoinCallClick(room.roomId) }
} }
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
@ -273,11 +274,11 @@ class ThreadedMessagesNode(
onUserDataClick = this::onUserDataClick, onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab -> onLinkClick = { url, customTab ->
onLinkClick( onLinkClick(
activity, activity = activity,
isDark, darkTheme = isDark,
url, url = url,
state.timelineState.eventSink, eventSink = state.timelineState.eventSink,
customTab customTab = customTab,
) )
}, },
onSendLocationClick = this::onSendLocationClick, onSendLocationClick = this::onSendLocationClick,

View file

@ -14,6 +14,7 @@ import dev.zacsweers.metro.binding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
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.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -74,21 +75,26 @@ class TimelineController(
} }
} }
suspend fun focusOnEvent(eventId: EventId): Result<Unit> { suspend fun focusOnEvent(eventId: EventId, threadRootId: ThreadId?): Result<EventFocusResult> {
return room.createTimeline(CreateTimelineParams.Focused(eventId)) return if (threadRootId != null) {
.onFailure { Result.success(EventFocusResult.IsInThread(threadRootId))
if (it is CancellationException) { } else {
throw it room.createTimeline(CreateTimelineParams.Focused(eventId))
} .onFailure {
} if (it is CancellationException) {
.map { newDetachedTimeline -> throw it
detachedTimelineFlow.getAndUpdate { current ->
if (current.isPresent) {
current.get().close()
} }
Optional.of(newDetachedTimeline)
} }
} .map { newDetachedTimeline ->
detachedTimelineFlow.getAndUpdate { current ->
if (current.isPresent) {
current.get().close()
}
Optional.of(newDetachedTimeline)
}
EventFocusResult.FocusedOnLive
}
}
} }
/** /**
@ -136,3 +142,8 @@ class TimelineController(
return currentTimelineFlow return currentTimelineFlow
} }
} }
sealed interface EventFocusResult {
data object FocusedOnLive : EventFocusResult
data class IsInThread(val threadId: ThreadId) : EventFocusResult
}

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
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.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
@ -207,7 +208,7 @@ class TimelinePresenter(
is TimelineEvents.NavigateToPredecessorOrSuccessorRoom -> { is TimelineEvents.NavigateToPredecessorOrSuccessorRoom -> {
// Navigate to the predecessor or successor room // Navigate to the predecessor or successor room
val serverNames = calculateServerNamesForRoom(room) val serverNames = calculateServerNamesForRoom(room)
navigator.onNavigateToRoom(event.roomId, serverNames) navigator.onNavigateToRoom(event.roomId, null, serverNames)
} }
is TimelineEvents.OpenThread -> { is TimelineEvents.OpenThread -> {
navigator.onOpenThread( navigator.onOpenThread(
@ -257,13 +258,39 @@ class TimelinePresenter(
} }
is FocusRequestState.Loading -> { is FocusRequestState.Loading -> {
val eventId = currentFocusRequestState.eventId val eventId = currentFocusRequestState.eventId
timelineController.focusOnEvent(eventId) val threadId = room.threadRootIdForEvent(eventId).getOrElse {
.onSuccess { focusRequestState = FocusRequestState.Failure(it)
focusRequestState = FocusRequestState.Success(eventId = eventId) return@LaunchedEffect
} }
.onFailure {
focusRequestState = FocusRequestState.Failure(it) if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
} // We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
focusRequestState = FocusRequestState.None
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
} else {
timelineController.focusOnEvent(eventId, threadId)
.onSuccess { result ->
when (result) {
is EventFocusResult.FocusedOnLive -> {
focusRequestState = FocusRequestState.Success(eventId = eventId)
}
is EventFocusResult.IsInThread -> {
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
if (currentThreadId == result.threadId) {
// It's the same thread, we just focus on the event
focusRequestState = FocusRequestState.Success(eventId = eventId)
} else {
focusRequestState = FocusRequestState.Success(eventId = result.threadId.asEventId())
// It's part of a thread we're not in, let's open it in another timeline
navigator.onOpenThread(result.threadId, eventId)
}
}
}
}
.onFailure {
focusRequestState = FocusRequestState.Failure(it)
}
}
} }
else -> Unit else -> Unit
} }
@ -341,7 +368,7 @@ class TimelinePresenter(
newMostRecentItemId != prevMostRecentItemIdValue newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewEvent) { if (hasNewEvent) {
val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event val newMostRecentEvent = newMostRecentItem
// Scroll to bottom if the new event is from me, even if sent from another device // Scroll to bottom if the new event is from me, even if sent from another device
val fromMe = newMostRecentEvent?.isMine == true val fromMe = newMostRecentEvent?.isMine == true
newEventState.value = if (fromMe) { newEventState.value = if (fromMe) {

View file

@ -22,7 +22,7 @@ class FakeMessagesNavigator(
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() }, private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
) : MessagesNavigator { ) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
@ -45,8 +45,8 @@ class FakeMessagesNavigator(
onPreviewAttachmentLambda(attachments, inReplyToEventId) onPreviewAttachmentLambda(attachments, inReplyToEventId)
} }
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
onNavigateToRoomLambda(roomId, serverNames) onNavigateToRoomLambda(roomId, eventId, serverNames)
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {

View file

@ -40,7 +40,7 @@ class TimelineControllerTest {
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
} }
assertThat(sut.isLive().first()).isTrue() assertThat(sut.isLive().first()).isTrue()
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline) assertThat(state).isEqualTo(detachedTimeline)
} }
@ -78,14 +78,14 @@ class TimelineControllerTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
} }
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline1) assertThat(state).isEqualTo(detachedTimeline1)
} }
assertThat(detachedTimeline1.closeCounter).isEqualTo(0) assertThat(detachedTimeline1.closeCounter).isEqualTo(0)
assertThat(detachedTimeline2.closeCounter).isEqualTo(0) assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
// Focus on another event should close the previous detached timeline // Focus on another event should close the previous detached timeline
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline2) assertThat(state).isEqualTo(detachedTimeline2)
} }
@ -124,7 +124,7 @@ class TimelineControllerTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
} }
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline) assertThat(state).isEqualTo(detachedTimeline)
} }
@ -171,11 +171,11 @@ class TimelineControllerTest {
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
} }
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline) assertThat(state).isEqualTo(detachedTimeline)
} }
@ -200,7 +200,7 @@ class TimelineControllerTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
} }
sut.focusOnEvent(AN_EVENT_ID) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline) assertThat(state).isEqualTo(detachedTimeline)
} }

View file

@ -31,7 +31,9 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -44,6 +46,8 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
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_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -535,7 +539,10 @@ class TimelinePresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { _ -> Result.success(null) },
),
) )
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
room = room, room = room,
@ -613,7 +620,10 @@ class TimelinePresenterTest {
timelineItems = flowOf(emptyList()), timelineItems = flowOf(emptyList()),
), ),
createTimelineResult = { Result.failure(RuntimeException("An error")) }, createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { _ -> Result.success(null) },
),
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -639,6 +649,246 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - focus on event in a thread opens the thread`() = runTest {
val threadId = A_THREAD_ID
val detachedTimeline = FakeTimeline(
mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2),
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(),
)
)
)
)
val liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList())
)
val room = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
advanceUntilIdle()
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
// The live timeline focuses in the thread root
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID.asEventId()))
// The thread is opened
openThreadLambda.assertions()
.isCalledOnce()
.with(
value(threadId),
value(AN_EVENT_ID),
)
}
}
@Test
fun `present - focus on event in a thread when in the same thread just moves the focus`() = runTest {
val threadId = A_THREAD_ID
val detachedTimeline = FakeTimeline(
mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2),
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(),
)
)
)
)
val liveTimeline = FakeTimeline(
mode = Timeline.Mode.Thread(threadId),
timelineItems = flowOf(emptyList())
)
val room = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
advanceUntilIdle()
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
// The live timeline focuses in the event directly since we are already in the thread
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID))
// The thread is not opened again
openThreadLambda.assertions().isNeverCalled()
}
}
@Test
fun `present - focus on event in a thread when in a different thread opens the new thread`() = runTest {
val currentThreadId = A_THREAD_ID
val detachedTimeline = FakeTimeline(
mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2),
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(),
)
)
)
)
val liveTimeline = FakeTimeline(
mode = Timeline.Mode.Thread(currentThreadId),
timelineItems = flowOf(emptyList())
)
val room = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
// Use a different thread id
threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) },
),
)
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
advanceUntilIdle()
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
// The live timeline focuses in the event directly since we are already in the thread
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID_2.asEventId()))
// The other thread is opened
openThreadLambda.assertions()
.isCalledOnce()
.with(
value(A_THREAD_ID_2),
value(AN_EVENT_ID),
)
}
}
@Test
fun `present - focus on event in a the room while in a thread of that room opens the room`() = runTest {
val detachedTimeline = FakeTimeline(
mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2),
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(),
)
)
)
)
val liveTimeline = FakeTimeline(
mode = Timeline.Mode.Thread(A_THREAD_ID),
timelineItems = flowOf(emptyList())
)
val room = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
// The event is in the main timeline, not in a thread
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
val openRoomLambda = lambdaRecorder { _: RoomId, _: EventId?, _: List<String> -> }
val navigator = FakeMessagesNavigator(onNavigateToRoomLambda = openRoomLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
advanceUntilIdle()
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
// The focus state will reset
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.None)
// The room is opened again
openRoomLambda.assertions()
.isCalledOnce()
.with(
value(room.roomId),
value(AN_EVENT_ID),
value(emptyList<String>())
)
}
}
@Test @Test
fun `present - show shield hide shield`() = runTest { fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter() val presenter = createTimelinePresenter()
@ -754,7 +1004,7 @@ class TimelinePresenterTest {
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
), ),
) )
val onNavigateToRoomLambda = lambdaRecorder<RoomId, List<String>, Unit> { _, _ -> } val onNavigateToRoomLambda = lambdaRecorder<RoomId, EventId?, List<String>, Unit> { _, _, _ -> }
val navigator = FakeMessagesNavigator( val navigator = FakeMessagesNavigator(
onNavigateToRoomLambda = onNavigateToRoomLambda onNavigateToRoomLambda = onNavigateToRoomLambda
) )
@ -766,6 +1016,8 @@ class TimelinePresenterTest {
.isCalledOnce() .isCalledOnce()
.with( .with(
value(A_ROOM_ID), value(A_ROOM_ID),
// No event id when navigating to a successor/predecessor room
value(null),
value(emptyList<String>()) value(emptyList<String>())
) )
} }

View file

@ -13,30 +13,36 @@ import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
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.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowGraph
import io.element.android.features.space.impl.leave.LeaveSpaceNode import io.element.android.features.space.impl.leave.LeaveSpaceNode
import io.element.android.features.space.impl.root.SpaceNode import io.element.android.features.space.impl.root.SpaceNode
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.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.di.DependencyInjectionGraphOwner
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.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@Inject @AssistedInject
class SpaceFlowNode( class SpaceFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
matrixClient: MatrixClient,
graphFactory: SpaceFlowGraph.Factory,
) : BaseFlowNode<SpaceFlowNode.NavTarget>( ) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -44,9 +50,11 @@ class SpaceFlowNode(
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ), DependencyInjectionGraphOwner {
private val inputs: SpaceEntryPoint.Inputs = inputs() private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single() private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId)
override val graph = graphFactory.create(spaceRoomList)
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
@ -56,6 +64,15 @@ class SpaceFlowNode(
data object Leave : NavTarget data object Leave : NavTarget
} }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
spaceRoomList.destroy()
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Leave -> { NavTarget.Leave -> {

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.di
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
@GraphExtension(SpaceFlowScope::class)
interface SpaceFlowGraph : NodeFactoriesBindings {
@ContributesTo(SessionScope::class)
@GraphExtension.Factory
interface Factory {
fun create(@Provides spaceRoomList: SpaceRoomList): SpaceFlowGraph
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.di
abstract class SpaceFlowScope private constructor()

View file

@ -15,20 +15,15 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SpaceFlowScope::class)
@AssistedInject @AssistedInject
class LeaveSpaceNode( class LeaveSpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: LeaveSpacePresenter.Factory, private val presenter: LeaveSpacePresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()

View file

@ -15,17 +15,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState 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.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@ -37,16 +34,8 @@ import kotlin.jvm.optionals.getOrNull
@Inject @Inject
class LeaveSpacePresenter( class LeaveSpacePresenter(
@Assisted private val inputs: SpaceEntryPoint.Inputs, private val spaceRoomList: SpaceRoomList,
matrixClient: MatrixClient,
) : Presenter<LeaveSpaceState> { ) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(inputs: SpaceEntryPoint.Inputs): LeaveSpacePresenter
}
private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId)
@Composable @Composable
override fun present(): LeaveSpaceState { override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()

View file

@ -19,24 +19,24 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.androidutils.R import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
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.spaces.SpaceRoomList
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SpaceFlowScope::class)
@AssistedInject @AssistedInject
class SpaceNode( class SpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory, private val presenter: SpacePresenter,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val spaceRoomList: SpaceRoomList,
private val acceptDeclineInviteView: AcceptDeclineInviteView, private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
@ -44,12 +44,10 @@ class SpaceNode(
fun onLeaveSpace() fun onLeaveSpace()
} }
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<Callback>().single() private val callback = plugins.filterIsInstance<Callback>().single()
private val presenter = presenterFactory.create(inputs)
private fun onShareRoom(context: Context) = lifecycleScope.launch { private fun onShareRoom(context: Context) = lifecycleScope.launch {
matrixClient.getRoom(inputs.roomId)?.use { room -> matrixClient.getRoom(spaceRoomList.roomId)?.use { room ->
room.getPermalink() room.getPermalink()
.onSuccess { permalink -> .onSuccess { permalink ->
context.startSharePlainTextIntent( context.startSharePlainTextIntent(

View file

@ -14,15 +14,12 @@ 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.rememberCoroutineScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.api.toInviteData
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
@ -44,20 +41,15 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@AssistedInject class SpacePresenter( @Inject
@Assisted private val inputs: SpaceEntryPoint.Inputs, class SpacePresenter(
private val spaceRoomList: SpaceRoomList,
private val client: MatrixClient, private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom, private val joinRoom: JoinRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> { ) : Presenter<SpaceState> {
@AssistedFactory fun interface Factory {
fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter
}
private val spaceRoomList = client.spaceService.spaceRoomList(inputs.roomId)
@Composable @Composable
override fun present(): SpaceState { override fun present(): SpaceState {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View file

@ -12,8 +12,12 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
import io.element.android.libraries.matrix.api.core.RoomId 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_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
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 org.junit.Rule import org.junit.Rule
@ -34,6 +38,12 @@ class DefaultSpaceEntryPointTest {
SpaceFlowNode( SpaceFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) }
)
),
graphFactory = FakeSpaceFlowGraph.Factory
) )
} }
val callback = object : SpaceEntryPoint.Callback { val callback = object : SpaceEntryPoint.Callback {

View file

@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.di
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.AssistedNodeFactory
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlin.reflect.KClass
class FakeSpaceFlowGraph : SpaceFlowGraph {
object Factory : SpaceFlowGraph.Factory {
override fun create(spaceRoomList: SpaceRoomList): SpaceFlowGraph {
return FakeSpaceFlowGraph()
}
}
override fun nodeFactories(): Map<KClass<out Node>, AssistedNodeFactory<*>> {
return emptyMap()
}
}

View file

@ -10,15 +10,11 @@
package io.element.android.features.space.impl.leave package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SPACE_NAME import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -29,15 +25,7 @@ import org.junit.Test
class LeaveSpacePresenterTest { class LeaveSpacePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter( val presenter = createLeaveSpacePresenter()
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = {
FakeSpaceRoomList()
},
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
assertThat(state.spaceName).isNull() assertThat(state.spaceName).isNull()
@ -51,11 +39,7 @@ class LeaveSpacePresenterTest {
fun `present - current space name`() = runTest { fun `present - current space name`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList() val fakeSpaceRoomList = FakeSpaceRoomList()
val presenter = createLeaveSpacePresenter( val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient( spaceRoomList = fakeSpaceRoomList,
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
) )
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
@ -71,12 +55,10 @@ class LeaveSpacePresenterTest {
} }
private fun createLeaveSpacePresenter( private fun createLeaveSpacePresenter(
inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
matrixClient: MatrixClient = FakeMatrixClient(),
): LeaveSpacePresenter { ): LeaveSpacePresenter {
return LeaveSpacePresenter( return LeaveSpacePresenter(
inputs = inputs, spaceRoomList = spaceRoomList,
matrixClient = matrixClient,
) )
} }
} }

View file

@ -16,7 +16,6 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteS
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.api.toInviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -31,7 +30,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -51,17 +49,8 @@ class SpacePresenterTest {
val paginateResult = lambdaRecorder<Result<Unit>> { val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit) Result.success(Unit)
} }
val presenter = createSpacePresenter( val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
client = FakeMatrixClient( val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
spaceService = FakeSpaceService(
spaceRoomListResult = {
FakeSpaceRoomList(
paginateResult = paginateResult,
)
},
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
assertThat(state.currentSpace).isNull() assertThat(state.currentSpace).isNull()
@ -81,17 +70,8 @@ class SpacePresenterTest {
val paginateResult = lambdaRecorder<Result<Unit>> { val paginateResult = lambdaRecorder<Result<Unit>> {
Result.success(Unit) Result.success(Unit)
} }
val presenter = createSpacePresenter( val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
client = FakeMatrixClient( val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
spaceService = FakeSpaceService(
spaceRoomListResult = {
FakeSpaceRoomList(
paginateResult = paginateResult,
)
},
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() advanceUntilIdle()
@ -104,25 +84,20 @@ class SpacePresenterTest {
@Test @Test
fun `present - has more to load value`() = runTest { fun `present - has more to load value`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList( val paginateResult = lambdaRecorder<Result<Unit>> {
paginateResult = { Result.success(Unit) }, Result.success(Unit)
) }
val presenter = createSpacePresenter( val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
client = FakeMatrixClient( val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() advanceUntilIdle()
assertThat(state.hasMoreToLoad).isTrue() assertThat(state.hasMoreToLoad).isTrue()
fakeSpaceRoomList.emitPaginationStatus( spaceRoomList.emitPaginationStatus(
SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false) SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)
) )
assertThat(awaitItem().hasMoreToLoad).isFalse() assertThat(awaitItem().hasMoreToLoad).isFalse()
fakeSpaceRoomList.emitPaginationStatus( spaceRoomList.emitPaginationStatus(
SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true) SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)
) )
assertThat(awaitItem().hasMoreToLoad).isTrue() assertThat(awaitItem().hasMoreToLoad).isTrue()
@ -131,44 +106,34 @@ class SpacePresenterTest {
@Test @Test
fun `present - current space value`() = runTest { fun `present - current space value`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList( val paginateResult = lambdaRecorder<Result<Unit>> {
paginateResult = { Result.success(Unit) }, Result.success(Unit)
) }
val presenter = createSpacePresenter( val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
client = FakeMatrixClient( val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() advanceUntilIdle()
assertThat(state.currentSpace).isNull() assertThat(state.currentSpace).isNull()
val aSpace = aSpaceRoom() val aSpace = aSpaceRoom()
fakeSpaceRoomList.emitCurrentSpace(aSpace) spaceRoomList.emitCurrentSpace(aSpace)
assertThat(awaitItem().currentSpace).isEqualTo(aSpace) assertThat(awaitItem().currentSpace).isEqualTo(aSpace)
} }
} }
@Test @Test
fun `present - children value`() = runTest { fun `present - children value`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList( val paginateResult = lambdaRecorder<Result<Unit>> {
paginateResult = { Result.success(Unit) }, Result.success(Unit)
) }
val presenter = createSpacePresenter( val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
client = FakeMatrixClient( val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
advanceUntilIdle() advanceUntilIdle()
assertThat(state.children).isEmpty() assertThat(state.children).isEmpty()
val aSpace = aSpaceRoom() val aSpace = aSpaceRoom()
fakeSpaceRoomList.emitSpaceRooms(listOf(aSpace)) spaceRoomList.emitSpaceRooms(listOf(aSpace))
assertThat(awaitItem().children).containsExactly(aSpace) assertThat(awaitItem().children).containsExactly(aSpace)
} }
} }
@ -195,11 +160,7 @@ class SpacePresenterTest {
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
) )
val presenter = createSpacePresenter( val presenter = createSpacePresenter(
client = FakeMatrixClient( spaceRoomList = fakeSpaceRoomList,
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
joinRoom = FakeJoinRoom( joinRoom = FakeJoinRoom(
lambda = joinRoom, lambda = joinRoom,
), ),
@ -253,11 +214,7 @@ class SpacePresenterTest {
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
) )
val presenter = createSpacePresenter( val presenter = createSpacePresenter(
client = FakeMatrixClient( spaceRoomList = fakeSpaceRoomList,
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
joinRoom = FakeJoinRoom( joinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) },
), ),
@ -312,11 +269,7 @@ class SpacePresenterTest {
paginateResult = { Result.success(Unit) }, paginateResult = { Result.success(Unit) },
) )
val presenter = createSpacePresenter( val presenter = createSpacePresenter(
client = FakeMatrixClient( spaceRoomList = fakeSpaceRoomList,
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
acceptDeclineInvitePresenter = { acceptDeclineInvitePresenter = {
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
eventSink = eventRecorder, eventSink = eventRecorder,
@ -348,8 +301,8 @@ class SpacePresenterTest {
} }
private fun TestScope.createSpacePresenter( private fun TestScope.createSpacePresenter(
inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID),
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
joinRoom: JoinRoom = FakeJoinRoom( joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) }, lambda = { _, _, _ -> Result.success(Unit) },
@ -357,8 +310,8 @@ class SpacePresenterTest {
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter { ): SpacePresenter {
return SpacePresenter( return SpacePresenter(
inputs = inputs,
client = client, client = client,
spaceRoomList = spaceRoomList,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom, joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,

View file

@ -155,7 +155,7 @@ class IncomingVerificationPresenter(
StateMachineState.RejectingIncomingVerification, StateMachineState.RejectingIncomingVerification,
null -> { null -> {
Step.Initial( Step.Initial(
deviceDisplayName = sessionVerificationRequestDetails.senderProfile.displayName ?: sessionVerificationRequestDetails.deviceId.value, deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
deviceId = sessionVerificationRequestDetails.deviceId, deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
isWaiting = machineState == StateMachineState.AcceptingIncomingVerification || isWaiting = machineState == StateMachineState.AcceptingIncomingVerification ||

View file

@ -22,7 +22,7 @@ data class IncomingVerificationState(
@Stable @Stable
sealed interface Step { sealed interface Step {
data class Initial( data class Initial(
val deviceDisplayName: String, val deviceDisplayName: String?,
val deviceId: DeviceId, val deviceId: DeviceId,
val formattedSignInTime: String, val formattedSignInTime: String,
val isWaiting: Boolean, val isWaiting: Boolean,

View file

@ -14,6 +14,7 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.FlowId
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.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
@ -55,26 +56,28 @@ internal fun aStepInitial(
internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession( internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession(
details = SessionVerificationRequestDetails( details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile( senderProfile = MatrixUser(
userId = UserId("@alice:example.com"), userId = UserId("@alice:example.com"),
displayName = "Alice", displayName = "Alice",
avatarUrl = null, avatarUrl = null,
), ),
flowId = FlowId("1234"), flowId = FlowId("1234"),
deviceId = DeviceId("ILAKNDNASDLK"), deviceId = DeviceId("ILAKNDNASDLK"),
deviceDisplayName = "a device name",
firstSeenTimestamp = 0, firstSeenTimestamp = 0,
) )
) )
internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User( internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
details = SessionVerificationRequestDetails( details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile( senderProfile = MatrixUser(
userId = UserId("@alice:example.com"), userId = UserId("@alice:example.com"),
displayName = "Alice", displayName = "Alice",
avatarUrl = null, avatarUrl = null,
), ),
flowId = FlowId("1234"), flowId = FlowId("1234"),
deviceId = DeviceId("ILAKNDNASDLK"), deviceId = DeviceId("ILAKNDNASDLK"),
deviceDisplayName = "a device name",
firstSeenTimestamp = 0, firstSeenTimestamp = 0,
) )
) )

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.focused
import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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
@ -214,9 +215,7 @@ private fun ContentInitial(
.padding(top = 24.dp), .padding(top = 24.dp),
) { ) {
VerificationUserProfileContent( VerificationUserProfileContent(
userId = request.details.senderProfile.userId, user = request.details.senderProfile,
displayName = request.details.senderProfile.displayName,
avatarUrl = request.details.senderProfile.avatarUrl,
) )
} }
} }
@ -292,3 +291,11 @@ internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificat
state = state, state = state,
) )
} }
@Preview
@Composable
internal fun IncomingVerificationViewA11yPreview() = ElementPreview {
IncomingVerificationView(
state = anIncomingVerificationState(),
)
}

View file

@ -34,7 +34,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun SessionDetailsView( fun SessionDetailsView(
deviceName: String, deviceName: String?,
deviceId: DeviceId, deviceId: DeviceId,
signInFormattedTimestamp: String, signInFormattedTimestamp: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -61,7 +61,7 @@ fun SessionDetailsView(
resourceId = CompoundDrawables.ic_compound_devices resourceId = CompoundDrawables.ic_compound_devices
) )
Text( Text(
text = deviceName, text = deviceName ?: deviceId.value,
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )
@ -87,9 +87,16 @@ fun SessionDetailsView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun SessionDetailsViewPreview() = ElementPreview { internal fun SessionDetailsViewPreview() = ElementPreview {
SessionDetailsView( Column {
deviceName = "Element X Android", SessionDetailsView(
deviceId = DeviceId("ILAKNDNASDLK"), deviceName = "Element X Android",
signInFormattedTimestamp = "12:34", deviceId = DeviceId("ILAKNDNASDLK"),
) signInFormattedTimestamp = "12:34",
)
SessionDetailsView(
deviceName = null,
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}
} }

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -23,7 +24,6 @@ import androidx.compose.ui.draw.clip
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.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
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.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -31,18 +31,21 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
/**
* Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049
*/
@Composable @Composable
fun VerificationUserProfileContent( fun VerificationUserProfileContent(
userId: UserId, user: MatrixUser,
displayName: String?,
avatarUrl: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarData = remember(userId, displayName, avatarUrl) { val avatarData = remember(user) {
AvatarData(id = userId.value, name = displayName, url = avatarUrl, size = AvatarSize.UserVerification) user.getAvatarData(AvatarSize.UserVerification)
} }
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -55,12 +58,20 @@ fun VerificationUserProfileContent(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
) )
Spacer(modifier = Modifier.padding(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = displayName ?: userId.value, style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary) Text(
text = user.getBestName(),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
if (displayName != null) { if (user.displayName.isNullOrEmpty().not()) {
Text(text = userId.value, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary) Text(
text = user.userId.value,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
} }
} }
} }
@ -72,8 +83,10 @@ internal fun VerificationUserProfileContentPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar drawableFallbackForImages = CommonDrawables.sample_avatar
) { ) {
VerificationUserProfileContent( VerificationUserProfileContent(
userId = UserId("@alice:example.com"), user = MatrixUser(
displayName = "Alice", userId = UserId("@alice:example.com"),
avatarUrl = "https://example.com/avatar.png", displayName = "Alice",
avatarUrl = "https://example.com/avatar.png",
)
) )
} }

View file

@ -12,6 +12,7 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@ -293,13 +294,14 @@ class IncomingVerificationPresenterTest {
private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession( private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession(
details = SessionVerificationRequestDetails( details = SessionVerificationRequestDetails(
senderProfile = SessionVerificationRequestDetails.SenderProfile( senderProfile = MatrixUser(
userId = A_USER_ID, userId = A_USER_ID,
displayName = "a device name", displayName = "a user name",
avatarUrl = null, avatarUrl = null,
), ),
flowId = FlowId("flowId"), flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID, deviceId = A_DEVICE_ID,
deviceDisplayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP, firstSeenTimestamp = A_TIMESTAMP,
) )
) )

View file

@ -244,7 +244,9 @@ interface BaseRoom : Closeable {
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
/** suspend fun threadRootIdForEvent(eventId: EventId): Result<ThreadId?>
/**
* Destroy the room and release all resources associated to it. * Destroy the room and release all resources associated to it.
*/ */
fun destroy() fun destroy()

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.api.spaces package io.element.android.libraries.matrix.api.spaces
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.Optional import java.util.Optional
@ -17,9 +18,13 @@ interface SpaceRoomList {
data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus
} }
val roomId: RoomId
val currentSpaceFlow: StateFlow<Optional<SpaceRoom>> val currentSpaceFlow: StateFlow<Optional<SpaceRoom>>
val spaceRoomsFlow: Flow<List<SpaceRoom>> val spaceRoomsFlow: Flow<List<SpaceRoom>>
val paginationStatusFlow: StateFlow<PaginationStatus> val paginationStatusFlow: StateFlow<PaginationStatus>
suspend fun paginate(): Result<Unit> suspend fun paginate(): Result<Unit>
fun destroy()
} }

View file

@ -10,20 +10,14 @@ package io.element.android.libraries.matrix.api.verification
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class SessionVerificationRequestDetails( data class SessionVerificationRequestDetails(
val senderProfile: SenderProfile, val senderProfile: MatrixUser,
val flowId: FlowId, val flowId: FlowId,
val deviceId: DeviceId, val deviceId: DeviceId,
val deviceDisplayName: String?,
val firstSeenTimestamp: Long, val firstSeenTimestamp: Long,
) : Parcelable { ) : Parcelable
@Parcelize
data class SenderProfile(
val userId: UserId,
val displayName: String?,
val avatarUrl: String?,
) : Parcelable
}

View file

@ -51,6 +51,7 @@ 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 io.element.android.libraries.matrix.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@ -75,7 +76,6 @@ import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionPathsProvider import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
@ -403,7 +403,7 @@ class RustMatrixClient(
override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(sessionDispatcher) { override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
innerClient.getProfile(userId.value).let(UserProfileMapper::map) innerClient.getProfile(userId.value).map()
} }
} }

View file

@ -5,17 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl.usersearch package io.element.android.libraries.matrix.impl.mapper
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.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile import org.matrix.rustcomponents.sdk.UserProfile
object UserProfileMapper { fun UserProfile.map() = MatrixUser(
fun map(userProfile: UserProfile): MatrixUser = userId = UserId(userId),
MatrixUser( displayName = displayName,
userId = UserId(userProfile.userId), avatarUrl = avatarUrl,
displayName = userProfile.displayName, )
avatarUrl = userProfile.avatarUrl,
)
}

View file

@ -322,4 +322,12 @@ class RustBaseRoom(
}) })
} }
} }
override suspend fun threadRootIdForEvent(eventId: EventId): Result<ThreadId?> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.loadOrFetchEvent(eventId.value).use {
it.threadRootEventId()?.let(::ThreadId)
}
}
}
} }

View file

@ -8,26 +8,31 @@
package io.element.android.libraries.matrix.impl.spaces package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
import java.util.Optional import java.util.Optional
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList( class RustSpaceRoomList(
override val roomId: RoomId,
private val innerProvider: suspend () -> InnerSpaceRoomList, private val innerProvider: suspend () -> InnerSpaceRoomList,
sessionCoroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper, spaceRoomMapper: SpaceRoomMapper,
) : SpaceRoomList { ) : SpaceRoomList {
private val inner = CompletableDeferred<InnerSpaceRoomList>() private val innerCompletable = CompletableDeferred<InnerSpaceRoomList>()
override val currentSpaceFlow = MutableStateFlow<Optional<SpaceRoom>>(Optional.empty()) override val currentSpaceFlow = MutableStateFlow<Optional<SpaceRoom>>(Optional.empty())
@ -41,37 +46,45 @@ class RustSpaceRoomList(
) )
init { init {
sessionCoroutineScope.launch { coroutineScope.launch {
inner.complete(innerProvider()) val inner = innerProvider()
} innerCompletable.complete(inner)
sessionCoroutineScope.launch {
inner.await().paginationStateFlow() inner.paginationStateFlow()
.onEach { paginationStatus -> .onEach { paginationStatus ->
paginationStatusFlow.emit(paginationStatus.into()) paginationStatusFlow.emit(paginationStatus.into())
} }
.collect() .launchIn(this)
}
sessionCoroutineScope.launch { inner.spaceListUpdateFlow()
inner.await().spaceListUpdateFlow()
.onEach { updates -> .onEach { updates ->
spaceListUpdateProcessor.postUpdates(updates) spaceListUpdateProcessor.postUpdates(updates)
} }
.collect() .launchIn(this)
}
sessionCoroutineScope.launch { inner.spaceUpdateFlow()
inner.await().spaceUpdateFlow()
.map { space -> space.map(spaceRoomMapper::map) } .map { space -> space.map(spaceRoomMapper::map) }
.onEach { space -> .onEach { space ->
currentSpaceFlow.emit(space) currentSpaceFlow.emit(space)
} }
.collect() .launchIn(this)
} }
} }
override suspend fun paginate(): Result<Unit> { override suspend fun paginate(): Result<Unit> {
return runCatchingExceptions { return runCatchingExceptions {
inner.await().paginate() innerCompletable.await().paginate()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun destroy() {
Timber.d("Destroying SpaceRoomList $roomId")
coroutineScope.cancel()
try {
innerCompletable.getCompleted().destroy()
} catch (_: Exception) {
// Ignore, we just want to make sure it's completed
} }
} }

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.spaces package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
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.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
@ -54,9 +55,11 @@ class RustSpaceService(
} }
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this")
return RustSpaceRoomList( return RustSpaceRoomList(
roomId = id,
innerProvider = { innerSpaceService.spaceRoomList(id.value) }, innerProvider = { innerSpaceService.spaceRoomList(id.value) },
sessionCoroutineScope = sessionCoroutineScope, coroutineScope = childCoroutineScope,
spaceRoomMapper = spaceRoomMapper, spaceRoomMapper = spaceRoomMapper,
) )
} }

View file

@ -8,13 +8,16 @@
package io.element.android.libraries.matrix.impl.usersearch package io.element.android.libraries.matrix.impl.usersearch
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.impl.mapper.map
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults import org.matrix.rustcomponents.sdk.SearchUsersResults
object UserSearchResultMapper { object UserSearchResultMapper {
fun map(result: SearchUsersResults): MatrixSearchUserResults { fun map(result: SearchUsersResults): MatrixSearchUserResults {
return MatrixSearchUserResults( return MatrixSearchUserResults(
results = result.results.map(UserProfileMapper::map).toImmutableList(), results = result.results
.map { userProfile -> userProfile.map() }
.toImmutableList(),
limited = result.limited, limited = result.limited,
) )
} }

View file

@ -12,22 +12,17 @@ import io.element.android.libraries.matrix.api.core.FlowId
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.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.mapper.map
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
import org.matrix.rustcomponents.sdk.UserProfile as RustUserProfile
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
senderProfile = senderProfile.map(), senderProfile = senderProfile.map(),
flowId = FlowId(flowId), flowId = FlowId(flowId),
deviceId = DeviceId(deviceId), deviceId = DeviceId(deviceId),
deviceDisplayName = deviceDisplayName,
firstSeenTimestamp = firstSeenTimestamp.toLong(), firstSeenTimestamp = firstSeenTimestamp.toLong(),
) )
fun RustUserProfile.map() = SessionVerificationRequestDetails.SenderProfile(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
)
fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming { fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming {
val details = map() val details = map()
return if (currentUserId == details.senderProfile.userId) { return if (currentUserId == details.senderProfile.userId) {

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl.usersearch package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -16,7 +16,7 @@ import org.junit.Test
class UserProfileMapperTest { class UserProfileMapperTest {
@Test @Test
fun map() { fun map() {
assertThat(UserProfileMapper.map(aRustUserProfile(A_USER_ID.value, "displayName", "avatarUrl"))) assertThat(aRustUserProfile(A_USER_ID.value, "displayName", "avatarUrl").map())
.isEqualTo(MatrixUser(A_USER_ID, "displayName", "avatarUrl")) .isEqualTo(MatrixUser(A_USER_ID, "displayName", "avatarUrl"))
} }
} }

View file

@ -11,9 +11,11 @@ package io.element.android.libraries.matrix.impl.spaces
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.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList
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
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -84,13 +86,15 @@ class RustSpaceRoomListTest {
} }
private fun TestScope.createRustSpaceRoomList( private fun TestScope.createRustSpaceRoomList(
roomId: RoomId = A_ROOM_ID,
innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(), innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(),
innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList }, innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList },
spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(),
): RustSpaceRoomList { ): RustSpaceRoomList {
return RustSpaceRoomList( return RustSpaceRoomList(
roomId = roomId,
innerProvider = innerProvider, innerProvider = innerProvider,
sessionCoroutineScope = backgroundScope, coroutineScope = backgroundScope,
spaceRoomMapper = spaceRoomMapper, spaceRoomMapper = spaceRoomMapper,
) )
} }

View file

@ -71,6 +71,7 @@ class FakeBaseRoom(
private val forgetResult: () -> Result<Unit> = { lambdaError() }, private val forgetResult: () -> Result<Unit> = { lambdaError() },
private val reportRoomResult: (String?) -> Result<Unit> = { lambdaError() }, private val reportRoomResult: (String?) -> Result<Unit> = { lambdaError() },
private val predecessorRoomResult: () -> PredecessorRoom? = { null }, private val predecessorRoomResult: () -> PredecessorRoom? = { null },
private val threadRootIdForEventResult: (EventId) -> Result<ThreadId?> = { lambdaError() },
) : BaseRoom { ) : BaseRoom {
private val _roomInfoFlow: MutableStateFlow<RoomInfo> = MutableStateFlow(initialRoomInfo) private val _roomInfoFlow: MutableStateFlow<RoomInfo> = MutableStateFlow(initialRoomInfo)
override val roomInfoFlow: StateFlow<RoomInfo> = _roomInfoFlow override val roomInfoFlow: StateFlow<RoomInfo> = _roomInfoFlow
@ -244,6 +245,10 @@ class FakeBaseRoom(
fun givenUpdateMembersResult(result: () -> Unit) { fun givenUpdateMembersResult(result: () -> Unit) {
updateMembersResult = result updateMembersResult = result
} }
override suspend fun threadRootIdForEvent(eventId: EventId): Result<ThreadId?> {
return threadRootIdForEventResult(eventId)
}
} }
fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues( fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues(

View file

@ -7,8 +7,10 @@
package io.element.android.libraries.matrix.test.spaces package io.element.android.libraries.matrix.test.spaces
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.Optional import java.util.Optional
class FakeSpaceRoomList( class FakeSpaceRoomList(
override val roomId: RoomId = A_ROOM_ID,
initialSpaceFlowValue: SpaceRoom? = null, initialSpaceFlowValue: SpaceRoom? = null,
initialSpaceRoomsValue: List<SpaceRoom> = emptyList(), initialSpaceRoomsValue: List<SpaceRoom> = emptyList(),
initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
@ -47,4 +50,8 @@ class FakeSpaceRoomList(
override suspend fun paginate(): Result<Unit> = simulateLongTask { override suspend fun paginate(): Result<Unit> = simulateLongTask {
paginateResult() paginateResult()
} }
override fun destroy() {
// No op
}
} }

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.ui.common"
}
dependencies {
implementation(libs.appyx.core)
implementation(projects.libraries.designsystem)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.ui.common.nodes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.node.node
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
*/
fun emptyNode(
buildContext: BuildContext,
): Node = node(buildContext) { modifier ->
EmptyView(modifier)
}
@Composable
private fun EmptyView(
modifier: Modifier = Modifier,
) = Box(
modifier = modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault),
)
@PreviewsDayNight
@Composable
internal fun EmptyViewPreview() = ElementPreview {
EmptyView(Modifier)
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.tests.konsist
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.ext.list.withParameter
import com.lemonappdev.konsist.api.verify.assertTrue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import org.junit.Test
class KonsistDiTest {
@Test
fun `class annotated with @Inject should not have constructors with @Assisted parameter`() {
Konsist
.scopeFromProject()
.classes()
.withAnnotationOf(Inject::class)
.assertTrue(
additionalMessage = "Class with @Assisted parameter in constructor should be annotated with @AssistedInject and not @Inject"
) { classDeclaration ->
classDeclaration.constructors
.withParameter { parameterDeclaration ->
parameterDeclaration.hasAnnotationOf(Assisted::class)
}
.isEmpty()
}
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d345035d1ee105f7fa1702a3f953df99b0bfce2b0cf48cc92e6248d80690e4b5 oid sha256:537781aec261b622c47345b48e8fbfd68e3cf0817bb36bf0e34e83769d6d6d21
size 14582 size 24337

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:15b1f5afddbd729f1ec01326280b11e00f2b7c84e816bd980eac96dc61db92f2 oid sha256:9e5bf90db9ac805e4054c8d9c759181768a61f13bd30092bdf98fe0a962144f8
size 13970 size 23678

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2eecd4b52a31208444d14b5017e42fc31df0391bb0ead311c01c9359a7e42f03
size 74367

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:17d9ed21a98412af097ec5f9a209ecfeb33de2c94a3aec2afc1443469106a4f9 oid sha256:f0d95c8515dfe38a3de02fb68a035e41bedac03abadae44e371a85e6f32c9174
size 38451 size 38460

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:78507911cefb2a6c7ab9b0ddf1a7a55bc4c13adadbd3a60075ca7eb89969a8b7 oid sha256:74ecd0909c13895cd8d2e3827fb05fba98bd61f315735b281fd5502a9ae02bd2
size 32351 size 32354

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:541458c40f7b0e7c1dd18ba9211564fb053c264e965a7aaaede4e39c11a6420c oid sha256:18e41a828de1e73f0909449c096bc7d9660e5b677075845657267db731b913ab
size 37505 size 37597

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f268e84b9d477429d817fc4b80ff99fbaff45434e236bae0ce05d656b4337675 oid sha256:6242746ae0ba35d6e2e4a9c1d4c2a5e9433015eb7711767342afe4386edecfa1
size 31620 size 31701

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:84870e3b5554f97ff8093286bbf246b74b3bfa787a8318304740f55492809721 oid sha256:43836f421a5f9ab68e4a3f6b3430a6d3daeec9f737bc1b20940a3fc9597057c3
size 12397 size 12451

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:72e7183662d01486545e6702faf22dd867cc47ed72a50b8cd2c24a307cef58c9 oid sha256:053fe3e5699e98280bd40008a26f92a64e34c0f3bda1a52434137745e1ed9cf2
size 12408 size 12360

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 3642

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 3659