diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt similarity index 84% rename from app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt rename to app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt index 8bba1dd50b..9ae8c54eb7 100644 --- a/app/src/main/kotlin/io/element/android/x/di/DefaultRoomComponentFactory.kt +++ b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt @@ -9,15 +9,15 @@ package io.element.android.x.di import dev.zacsweers.metro.ContributesBinding 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.matrix.api.room.JoinedRoom @ContributesBinding(SessionScope::class) @Inject -class DefaultRoomComponentFactory( +class DefaultRoomGraphFactory( private val sessionGraph: SessionGraph, -) : RoomComponentFactory { +) : RoomGraphFactory { override fun create(room: JoinedRoom): Any { return sessionGraph.roomGraphFactory .create(room, room) diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt index e452bc2a9f..e48dd52daf 100644 --- a/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt +++ b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt @@ -7,18 +7,15 @@ package io.element.android.x.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.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.JoinedRoom @GraphExtension(RoomScope::class) interface RoomGraph : NodeFactoriesBindings { - @ContributesTo(SessionScope::class) @GraphExtension.Factory interface Factory { fun create( diff --git a/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt b/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt index 255c6c7ab9..3782b00a58 100644 --- a/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt +++ b/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt @@ -7,8 +7,6 @@ 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.Provides import io.element.android.libraries.architecture.NodeFactoriesBindings @@ -19,7 +17,6 @@ import io.element.android.libraries.matrix.api.MatrixClient interface SessionGraph : NodeFactoriesBindings { val roomGraphFactory: RoomGraph.Factory - @ContributesTo(AppScope::class) @GraphExtension.Factory interface Factory { fun create(@Provides matrixClient: MatrixClient): SessionGraph diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index b200672f94..7f487a1e63 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) implementation(projects.features.login.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a15c87ed77..66df9a6dd5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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.replace import com.bumble.appyx.navmodel.backstack.operation.singleTop -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.annotations.ContributesNode 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.VerificationRequest 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first @@ -282,7 +281,7 @@ class LoggedInFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Placeholder -> createNode(buildContext) + NavTarget.Placeholder -> emptyNode(buildContext) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { @@ -549,13 +548,6 @@ class LoggedInFlowNode( } } -@ContributesNode(AppScope::class) -@Inject -class PlaceholderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, -) : Node(buildContext, plugins = plugins) - @Parcelize private class AttachRoomOperation( val roomTarget: LoggedInFlowNode.NavTarget.Room, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index d71fbf2eef..93df005c76 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -11,15 +11,11 @@ import android.content.Intent import android.os.Parcelable 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope 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.state.MutableSavedStateMap 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.core.uri.ensureProtocol 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.FeatureFlags 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.sessionstorage.api.LoggedInState 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.launchIn import kotlinx.coroutines.flow.onEach @@ -219,9 +215,10 @@ import timber.log.Timber override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.LoggedInFlow -> { - val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { - Timber.w("Couldn't find any session, go through SplashScreen") - } + val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) + ?: return emptyNode(buildContext).also { + Timber.w("Couldn't find any session, go through SplashScreen") + } val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) val callback = object : LoggedInAppScopeFlowNode.Callback { override fun onOpenBugReport() { @@ -252,7 +249,7 @@ import timber.log.Timber ) ).build() } - NavTarget.SplashScreen -> splashNode(buildContext) + NavTarget.SplashScreen -> emptyNode(buildContext) NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { 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) { val resolvedIntent = intentResolver.resolve(intent) ?: return when (resolvedIntent) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt similarity index 90% rename from appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt rename to appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt index fc8f2c175b..ae0a3a81f2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/RoomComponentFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt @@ -9,6 +9,6 @@ package io.element.android.appnav.di import io.element.android.libraries.matrix.api.room.JoinedRoom -fun interface RoomComponentFactory { +fun interface RoomGraphFactory { fun create(room: JoinedRoom): Any } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 9ff05ac49a..f0f8dff3e7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -20,7 +20,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject 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.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint @@ -56,7 +56,7 @@ class JoinedRoomLoadedFlowNode( private val sessionCoroutineScope: CoroutineScope, private val matrixClient: MatrixClient, private val activeRoomsHolder: ActiveRoomsHolder, - roomComponentFactory: RoomComponentFactory, + roomGraphFactory: RoomGraphFactory, ) : BaseFlowNode( backstack = BackStack( initialElement = when (val input = plugins.filterIsInstance().first().initialElement) { @@ -83,7 +83,7 @@ class JoinedRoomLoadedFlowNode( private val inputs: Inputs = inputs() private val callbacks = plugins.filterIsInstance() - override val graph = roomComponentFactory.create(inputs.room) + override val graph = roomGraphFactory.create(inputs.room) init { lifecycle.subscribe( diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index bbaa196520..dfb2638dc1 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -17,7 +17,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.google.common.truth.Truth.assertThat -import io.element.android.appnav.di.RoomComponentFactory +import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode 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 { return Unit } @@ -110,7 +110,7 @@ class JoinedRoomLoadedFlowNodeTest { roomDetailsEntryPoint = roomDetailsEntryPoint, appNavigationStateService = FakeAppNavigationStateService(), sessionCoroutineScope = this, - roomComponentFactory = FakeRoomComponentFactory(), + roomGraphFactory = FakeRoomGraphFactory(), matrixClient = FakeMatrixClient(), activeRoomsHolder = activeRoomsHolder, ) diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt index 8467ef7bcb..2c3f77f208 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt @@ -19,7 +19,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject 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.ChangeRoomMemberRolesListType import io.element.android.libraries.architecture.NodeInputs @@ -36,7 +36,7 @@ import kotlinx.parcelize.Parcelize class ChangeRoomMemberRolesRootNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - roomComponentFactory: RoomComponentFactory, + roomGraphFactory: RoomGraphFactory, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -54,7 +54,7 @@ class ChangeRoomMemberRolesRootNode( private val inputs = inputs() - override val graph = roomComponentFactory.create(inputs.joinedRoom) + override val graph = roomGraphFactory.create(inputs.joinedRoom) override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return createNode( diff --git a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPointTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPointTest.kt index 5f68fe5970..621af8edaf 100644 --- a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPointTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPointTest.kt @@ -26,7 +26,7 @@ class DefaultChangeRoomMemberRolesEntyPointTest { ChangeRoomMemberRolesRootNode( buildContext = buildContext, plugins = plugins, - roomComponentFactory = { }, + roomGraphFactory = { }, ) } val room = FakeJoinedRoom() diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 8f940ed29a..d7d61f6d8d 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) implementation(projects.features.analytics.api) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index b625b07f12..6552a3b360 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -8,10 +8,7 @@ package io.element.android.features.ftue.impl import android.os.Parcelable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope 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.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.replace -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.Inject import io.element.android.annotations.ContributesNode import io.element.android.features.analytics.api.AnalyticsEntryPoint 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.BaseFlowNode 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.ui.common.nodes.emptyNode import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -88,7 +83,7 @@ class FtueFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Placeholder -> { - createNode(buildContext) + emptyNode(buildContext) } is NavTarget.SessionVerification -> { val callback = object : FtueSessionVerificationFlowNode.Callback { @@ -147,17 +142,3 @@ class FtueFlowNode( BackstackView() } } - -@ContributesNode(AppScope::class) -@Inject -class PlaceholderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, -) : Node(buildContext, plugins = plugins) { - @Composable - override fun View(modifier: Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } -} diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index 011d4919e7..998b763804 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) implementation(projects.features.logout.api) + implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) implementation(projects.libraries.sessionStorage.api) implementation(projects.services.appnavstate.api) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index cfde6db84f..01aeaabe89 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -14,7 +14,6 @@ import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.lifecycle.subscribe 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.navmodel.backstack.BackStack 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.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.ui.common.nodes.emptyNode import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -42,7 +42,7 @@ class LockScreenSettingsFlowNode( private val pinCodeManager: PinCodeManager, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.Unknown, + initialElement = NavTarget.Loading, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -50,7 +50,7 @@ class LockScreenSettingsFlowNode( ) { sealed interface NavTarget : Parcelable { @Parcelize - data object Unknown : NavTarget + data object Loading : NavTarget @Parcelize data object Unlock : NavTarget @@ -94,6 +94,9 @@ class LockScreenSettingsFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { + NavTarget.Loading -> { + emptyNode(buildContext) + } NavTarget.Unlock -> { val callback = object : PinUnlockNode.Callback { override fun onUnlock() { @@ -113,7 +116,6 @@ class LockScreenSettingsFlowNode( } createNode(buildContext, plugins = listOf(callback)) } - NavTarget.Unknown -> node(buildContext) { } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 74c477d7cf..af613e2520 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -459,6 +459,10 @@ class MessagesFlowNode( analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) elementCallEntryPoint.startCall(callType) } + + override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { + backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId)) + } } createNode(buildContext, listOf(inputs, callback)) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 250e271880..fc417fc029 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -21,6 +21,6 @@ interface MessagesNavigator { fun onReportContentClick(eventId: EventId, senderId: UserId) fun onEditPollClick(eventId: EventId) fun onPreviewAttachment(attachments: ImmutableList, inReplyToEventId: EventId?) - fun onNavigateToRoom(roomId: RoomId, serverNames: List) + fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index c934e5e90c..d4283d19d5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -92,6 +92,14 @@ class MessagesNode( private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { + private val callbacks = plugins() + + data class Inputs( + val focusedEventId: EventId?, + ) : NodeInputs + + private val inputs = inputs() + private val timelineController = TimelineController(room, room.liveTimeline) private val presenter = presenterFactory.create( navigator = this, @@ -99,18 +107,12 @@ class MessagesNode( timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), actionListPresenter = actionListPresenterFactory.create( postProcessor = TimelineItemActionPostProcessor.Default, - timelineMode = timelineController.mainTimelineMode() + timelineMode = timelineController.mainTimelineMode(), ), timelineController = timelineController, ) - private val callbacks = plugins() - - data class Inputs(val focusedEventId: EventId?) : NodeInputs - - private val inputs = inputs() interface Callback : Plugin { - fun onRoomDetailsClick() fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean fun onPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun onUserDataClick(userId: UserId) @@ -122,9 +124,10 @@ class MessagesNode( fun onCreatePollClick() fun onEditPollClick(eventId: EventId) fun onJoinCallClick(roomId: RoomId) + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun onRoomDetailsClick() fun onViewAllPinnedEvents() fun onViewKnockRequests() - fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } override fun onBuilt() { @@ -143,6 +146,14 @@ class MessagesNode( 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 { // Note: cannot use `callbacks.all { it.onEventClick(event) }` because: // - 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) } } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) { + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { if (roomId == room.roomId) { displaySameRoomToast() } 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) } } } @@ -236,10 +247,6 @@ class MessagesNode( callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) } } - private fun onViewAllPinnedMessagesClick() { - callbacks.forEach { it.onViewAllPinnedEvents() } - } - private fun onSendLocationClick() { callbacks.forEach { it.onSendLocationClick() } } @@ -252,10 +259,6 @@ class MessagesNode( callbacks.forEach { it.onJoinCallClick(room.roomId) } } - private fun onViewKnockRequestsClick() { - callbacks.forEach { it.onViewKnockRequests() } - } - private fun displaySameRoomToast() { context.toast(CommonStrings.screen_room_permalink_same_room_android) } @@ -291,7 +294,15 @@ class MessagesNode( } }, 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, onCreatePollClick = this::onCreatePollClick, onJoinCallClick = this::onJoinCallClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 1908427759..f732def95f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl.threads import android.app.Activity -import android.content.Context import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable 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.libraries.androidutils.browser.openUrlInChromeCustomTab 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.inputs import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.designsystem.utils.OnLifecycleEvent 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.matrix.api.analytics.toAnalyticsViewRoom 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.ThreadId 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.PermalinkParser 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.item.TimelineItemDebugInfo 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -77,7 +75,6 @@ import kotlinx.coroutines.runBlocking class ThreadedMessagesNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - @ApplicationContext private val context: Context, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom, private val analyticsService: AnalyticsService, @@ -125,6 +122,7 @@ class ThreadedMessagesNode( fun onCreatePollClick() fun onEditPollClick(eventId: EventId) fun onJoinCallClick(roomId: RoomId) + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } override fun onBuilt() { @@ -191,8 +189,11 @@ class ThreadedMessagesNode( if (eventId != null) { eventSink(TimelineEvents.FocusOnEvent(eventId)) } else { - // Click on the same room, ignore - displaySameRoomToast() + // Click on the same room, navigate up + // 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 { callbacks.forEach { it.onPermalinkClick(roomLink) } @@ -219,7 +220,14 @@ class ThreadedMessagesNode( callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) } } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) = Unit + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + 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() { callbacks.forEach { it.onSendLocationClick() } @@ -233,13 +241,6 @@ class ThreadedMessagesNode( 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 override fun View(modifier: Modifier) { val activity = requireNotNull(LocalActivity.current) @@ -273,11 +274,11 @@ class ThreadedMessagesNode( onUserDataClick = this::onUserDataClick, onLinkClick = { url, customTab -> onLinkClick( - activity, - isDark, - url, - state.timelineState.eventSink, - customTab + activity = activity, + darkTheme = isDark, + url = url, + eventSink = state.timelineState.eventSink, + customTab = customTab, ) }, onSendLocationClick = this::onSendLocationClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index a0f4c2ce0e..779ebe984a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -14,6 +14,7 @@ import dev.zacsweers.metro.binding import io.element.android.features.messages.impl.timeline.di.LiveTimeline import io.element.android.libraries.di.RoomScope 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.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -74,21 +75,26 @@ class TimelineController( } } - suspend fun focusOnEvent(eventId: EventId): Result { - return room.createTimeline(CreateTimelineParams.Focused(eventId)) - .onFailure { - if (it is CancellationException) { - throw it - } - } - .map { newDetachedTimeline -> - detachedTimelineFlow.getAndUpdate { current -> - if (current.isPresent) { - current.get().close() + suspend fun focusOnEvent(eventId: EventId, threadRootId: ThreadId?): Result { + return if (threadRootId != null) { + Result.success(EventFocusResult.IsInThread(threadRootId)) + } else { + room.createTimeline(CreateTimelineParams.Focused(eventId)) + .onFailure { + if (it is CancellationException) { + throw it } - 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 } } + +sealed interface EventFocusResult { + data object FocusedOnLive : EventFocusResult + data class IsInThread(val threadId: ThreadId) : EventFocusResult +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index c1f096ccb7..f03a1e8903 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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.matrix.api.core.EventId 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.MessageEventType import io.element.android.libraries.matrix.api.room.isDm @@ -207,7 +208,7 @@ class TimelinePresenter( is TimelineEvents.NavigateToPredecessorOrSuccessorRoom -> { // Navigate to the predecessor or successor room val serverNames = calculateServerNamesForRoom(room) - navigator.onNavigateToRoom(event.roomId, serverNames) + navigator.onNavigateToRoom(event.roomId, null, serverNames) } is TimelineEvents.OpenThread -> { navigator.onOpenThread( @@ -257,13 +258,39 @@ class TimelinePresenter( } is FocusRequestState.Loading -> { val eventId = currentFocusRequestState.eventId - timelineController.focusOnEvent(eventId) - .onSuccess { - focusRequestState = FocusRequestState.Success(eventId = eventId) - } - .onFailure { - focusRequestState = FocusRequestState.Failure(it) - } + val threadId = room.threadRootIdForEvent(eventId).getOrElse { + focusRequestState = FocusRequestState.Failure(it) + return@LaunchedEffect + } + + 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 } @@ -341,7 +368,7 @@ class TimelinePresenter( newMostRecentItemId != prevMostRecentItemIdValue 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 val fromMe = newMostRecentEvent?.isMine == true newEventState.value = if (fromMe) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 7320671694..bb59ca8551 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -22,7 +22,7 @@ class FakeMessagesNavigator( private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, - private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List) -> Unit = { _, _ -> lambdaError() }, + private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List) -> Unit = { _, _, _ -> lambdaError() }, private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, ) : MessagesNavigator { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { @@ -45,8 +45,8 @@ class FakeMessagesNavigator( onPreviewAttachmentLambda(attachments, inReplyToEventId) } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) { - onNavigateToRoomLambda(roomId, serverNames) + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + onNavigateToRoomLambda(roomId, eventId, serverNames) } override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 71d295ba8b..524cb3e1e8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -40,7 +40,7 @@ class TimelineControllerTest { assertThat(state).isEqualTo(liveTimeline) } assertThat(sut.isLive().first()).isTrue() - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -78,14 +78,14 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline1) } assertThat(detachedTimeline1.closeCounter).isEqualTo(0) assertThat(detachedTimeline2.closeCounter).isEqualTo(0) // Focus on another event should close the previous detached timeline - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline2) } @@ -124,7 +124,7 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -171,11 +171,11 @@ class TimelineControllerTest { ) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -200,7 +200,7 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 8710af8bda..8da614f67e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -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.matrix.api.core.EventId 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.asEventId 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.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_2 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_2 import io.element.android.libraries.matrix.test.A_USER_ID @@ -535,7 +539,10 @@ class TimelinePresenterTest { val room = FakeJoinedRoom( liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, - baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), ) val presenter = createTimelinePresenter( room = room, @@ -613,7 +620,10 @@ class TimelinePresenterTest { timelineItems = flowOf(emptyList()), ), 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) { @@ -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 -> } + 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()) + ) + } + } + @Test fun `present - show shield hide shield`() = runTest { val presenter = createTimelinePresenter() @@ -754,7 +1004,7 @@ class TimelinePresenterTest { canUserSendMessageResult = { _, _ -> Result.success(true) }, ), ) - val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _ -> } + val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> } val navigator = FakeMessagesNavigator( onNavigateToRoomLambda = onNavigateToRoomLambda ) @@ -766,6 +1016,8 @@ class TimelinePresenterTest { .isCalledOnce() .with( value(A_ROOM_ID), + // No event id when navigating to a successor/predecessor room + value(null), value(emptyList()) ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index 3fec810cb2..fffdeb6246 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -13,30 +13,36 @@ import android.os.Parcelable import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push 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.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.root.SpaceNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode 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.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.parcelize.Parcelize @ContributesNode(SessionScope::class) -@Inject +@AssistedInject class SpaceFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, + matrixClient: MatrixClient, + graphFactory: SpaceFlowGraph.Factory, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -44,9 +50,11 @@ class SpaceFlowNode( ), buildContext = buildContext, plugins = plugins, -) { +), DependencyInjectionGraphOwner { private val inputs: SpaceEntryPoint.Inputs = inputs() private val callback = plugins.filterIsInstance().single() + private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId) + override val graph = graphFactory.create(spaceRoomList) sealed interface NavTarget : Parcelable { @Parcelize @@ -56,6 +64,15 @@ class SpaceFlowNode( data object Leave : NavTarget } + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onDestroy = { + spaceRoomList.destroy() + } + ) + } + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Leave -> { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt new file mode 100644 index 0000000000..b1dac522b4 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt @@ -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 + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt new file mode 100644 index 0000000000..77fb07f871 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt @@ -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() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt index 0973092994..df313481a1 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt @@ -15,20 +15,15 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.space.api.SpaceEntryPoint -import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.SessionScope +import io.element.android.features.space.impl.di.SpaceFlowScope -@ContributesNode(SessionScope::class) +@ContributesNode(SpaceFlowScope::class) @AssistedInject class LeaveSpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - presenterFactory: LeaveSpacePresenter.Factory, + private val presenter: LeaveSpacePresenter, ) : Node(buildContext, plugins = plugins) { - private val inputs: SpaceEntryPoint.Inputs = inputs() - private val presenter = presenterFactory.create(inputs) - @Composable override fun View(modifier: Modifier) { val state = presenter.present() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt index c7be10d956..7af18c1b6d 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt @@ -15,17 +15,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory 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.AsyncData import io.element.android.libraries.architecture.Presenter 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.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -37,16 +34,8 @@ import kotlin.jvm.optionals.getOrNull @Inject class LeaveSpacePresenter( - @Assisted private val inputs: SpaceEntryPoint.Inputs, - matrixClient: MatrixClient, + private val spaceRoomList: SpaceRoomList, ) : Presenter { - @AssistedFactory - fun interface Factory { - fun create(inputs: SpaceEntryPoint.Inputs): LeaveSpacePresenter - } - - private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId) - @Composable override fun present(): LeaveSpaceState { val coroutineScope = rememberCoroutineScope() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt index 5c2cae4128..52c3472182 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -19,24 +19,24 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode 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.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.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch import timber.log.Timber -@ContributesNode(SessionScope::class) +@ContributesNode(SpaceFlowScope::class) @AssistedInject class SpaceNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - presenterFactory: SpacePresenter.Factory, + private val presenter: SpacePresenter, private val matrixClient: MatrixClient, + private val spaceRoomList: SpaceRoomList, private val acceptDeclineInviteView: AcceptDeclineInviteView, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { @@ -44,12 +44,10 @@ class SpaceNode( fun onLeaveSpace() } - private val inputs: SpaceEntryPoint.Inputs = inputs() private val callback = plugins.filterIsInstance().single() - private val presenter = presenterFactory.create(inputs) private fun onShareRoom(context: Context) = lifecycleScope.launch { - matrixClient.getRoom(inputs.roomId)?.use { room -> + matrixClient.getRoom(spaceRoomList.roomId)?.use { room -> room.getPermalink() .onSuccess { permalink -> context.startSharePlainTextIntent( diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 1909926716..7a3481bb73 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -14,15 +14,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.JoinedRoom 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.AcceptDeclineInviteState 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.Presenter import io.element.android.libraries.core.coroutine.mapState @@ -44,20 +41,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.jvm.optionals.getOrNull -@AssistedInject class SpacePresenter( - @Assisted private val inputs: SpaceEntryPoint.Inputs, +@Inject +class SpacePresenter( + private val spaceRoomList: SpaceRoomList, private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, private val joinRoom: JoinRoom, private val acceptDeclineInvitePresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { - @AssistedFactory fun interface Factory { - fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter - } - - private val spaceRoomList = client.spaceService.spaceRoomList(inputs.roomId) - @Composable override fun present(): SpaceState { LaunchedEffect(Unit) { diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index 0410944dbb..007c28e3a6 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -12,8 +12,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat 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.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.node.TestParentNode import org.junit.Rule @@ -34,6 +38,12 @@ class DefaultSpaceEntryPointTest { SpaceFlowNode( buildContext = buildContext, plugins = plugins, + matrixClient = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) } + ) + ), + graphFactory = FakeSpaceFlowGraph.Factory ) } val callback = object : SpaceEntryPoint.Callback { diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt new file mode 100644 index 0000000000..09263ff52d --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt @@ -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, AssistedNodeFactory<*>> { + return emptyMap() + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt index 5129181306..ee8962a345 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt @@ -10,15 +10,11 @@ package io.element.android.features.space.impl.leave 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.AsyncData -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList 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.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -29,15 +25,7 @@ import org.junit.Test class LeaveSpacePresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = createLeaveSpacePresenter( - matrixClient = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { - FakeSpaceRoomList() - }, - ), - ), - ) + val presenter = createLeaveSpacePresenter() presenter.test { val state = awaitItem() assertThat(state.spaceName).isNull() @@ -51,11 +39,7 @@ class LeaveSpacePresenterTest { fun `present - current space name`() = runTest { val fakeSpaceRoomList = FakeSpaceRoomList() val presenter = createLeaveSpacePresenter( - matrixClient = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), + spaceRoomList = fakeSpaceRoomList, ) presenter.test { val state = awaitItem() @@ -71,12 +55,10 @@ class LeaveSpacePresenterTest { } private fun createLeaveSpacePresenter( - inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), - matrixClient: MatrixClient = FakeMatrixClient(), + spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), ): LeaveSpacePresenter { return LeaveSpacePresenter( - inputs = inputs, - matrixClient = matrixClient, + spaceRoomList = spaceRoomList, ) } } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 9a40a8f18b..731fdb85a6 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -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.toInviteData 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.Presenter 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.room.join.FakeJoinRoom 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.tests.testutils.EventsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -51,17 +49,8 @@ class SpacePresenterTest { val paginateResult = lambdaRecorder> { Result.success(Unit) } - val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { - FakeSpaceRoomList( - paginateResult = paginateResult, - ) - }, - ), - ), - ) + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() assertThat(state.currentSpace).isNull() @@ -81,17 +70,8 @@ class SpacePresenterTest { val paginateResult = lambdaRecorder> { Result.success(Unit) } - val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { - FakeSpaceRoomList( - paginateResult = paginateResult, - ) - }, - ), - ), - ) + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() advanceUntilIdle() @@ -104,25 +84,20 @@ class SpacePresenterTest { @Test fun `present - has more to load value`() = runTest { - val fakeSpaceRoomList = FakeSpaceRoomList( - paginateResult = { Result.success(Unit) }, - ) - val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), - ) + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() advanceUntilIdle() assertThat(state.hasMoreToLoad).isTrue() - fakeSpaceRoomList.emitPaginationStatus( + spaceRoomList.emitPaginationStatus( SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false) ) assertThat(awaitItem().hasMoreToLoad).isFalse() - fakeSpaceRoomList.emitPaginationStatus( + spaceRoomList.emitPaginationStatus( SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true) ) assertThat(awaitItem().hasMoreToLoad).isTrue() @@ -131,44 +106,34 @@ class SpacePresenterTest { @Test fun `present - current space value`() = runTest { - val fakeSpaceRoomList = FakeSpaceRoomList( - paginateResult = { Result.success(Unit) }, - ) - val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), - ) + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() advanceUntilIdle() assertThat(state.currentSpace).isNull() val aSpace = aSpaceRoom() - fakeSpaceRoomList.emitCurrentSpace(aSpace) + spaceRoomList.emitCurrentSpace(aSpace) assertThat(awaitItem().currentSpace).isEqualTo(aSpace) } } @Test fun `present - children value`() = runTest { - val fakeSpaceRoomList = FakeSpaceRoomList( - paginateResult = { Result.success(Unit) }, - ) - val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), - ) + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() advanceUntilIdle() assertThat(state.children).isEmpty() val aSpace = aSpaceRoom() - fakeSpaceRoomList.emitSpaceRooms(listOf(aSpace)) + spaceRoomList.emitSpaceRooms(listOf(aSpace)) assertThat(awaitItem().children).containsExactly(aSpace) } } @@ -195,11 +160,7 @@ class SpacePresenterTest { paginateResult = { Result.success(Unit) }, ) val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), + spaceRoomList = fakeSpaceRoomList, joinRoom = FakeJoinRoom( lambda = joinRoom, ), @@ -253,11 +214,7 @@ class SpacePresenterTest { paginateResult = { Result.success(Unit) }, ) val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), + spaceRoomList = fakeSpaceRoomList, joinRoom = FakeJoinRoom( lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, ), @@ -312,11 +269,7 @@ class SpacePresenterTest { paginateResult = { Result.success(Unit) }, ) val presenter = createSpacePresenter( - client = FakeMatrixClient( - spaceService = FakeSpaceService( - spaceRoomListResult = { fakeSpaceRoomList }, - ), - ), + spaceRoomList = fakeSpaceRoomList, acceptDeclineInvitePresenter = { anAcceptDeclineInviteState( eventSink = eventRecorder, @@ -348,8 +301,8 @@ class SpacePresenterTest { } private fun TestScope.createSpacePresenter( - inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), client: MatrixClient = FakeMatrixClient(), + spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), joinRoom: JoinRoom = FakeJoinRoom( lambda = { _, _, _ -> Result.success(Unit) }, @@ -357,8 +310,8 @@ class SpacePresenterTest { acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, ): SpacePresenter { return SpacePresenter( - inputs = inputs, client = client, + spaceRoomList = spaceRoomList, seenInvitesStore = seenInvitesStore, joinRoom = joinRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 84328802ed..176176a895 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -155,7 +155,7 @@ class IncomingVerificationPresenter( StateMachineState.RejectingIncomingVerification, null -> { Step.Initial( - deviceDisplayName = sessionVerificationRequestDetails.senderProfile.displayName ?: sessionVerificationRequestDetails.deviceId.value, + deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, deviceId = sessionVerificationRequestDetails.deviceId, formattedSignInTime = formattedSignInTime, isWaiting = machineState == StateMachineState.AcceptingIncomingVerification || diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt index fdc9f373d4..00bffa4ce3 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -22,7 +22,7 @@ data class IncomingVerificationState( @Stable sealed interface Step { data class Initial( - val deviceDisplayName: String, + val deviceDisplayName: String?, val deviceId: DeviceId, val formattedSignInTime: String, val isWaiting: Boolean, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt index cdad2669d6..478e696889 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt @@ -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.FlowId 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.VerificationRequest @@ -55,26 +56,28 @@ internal fun aStepInitial( internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession( details = SessionVerificationRequestDetails( - senderProfile = SessionVerificationRequestDetails.SenderProfile( + senderProfile = MatrixUser( userId = UserId("@alice:example.com"), displayName = "Alice", avatarUrl = null, ), flowId = FlowId("1234"), deviceId = DeviceId("ILAKNDNASDLK"), + deviceDisplayName = "a device name", firstSeenTimestamp = 0, ) ) internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User( details = SessionVerificationRequestDetails( - senderProfile = SessionVerificationRequestDetails.SenderProfile( + senderProfile = MatrixUser( userId = UserId("@alice:example.com"), displayName = "Alice", avatarUrl = null, ), flowId = FlowId("1234"), deviceId = DeviceId("ILAKNDNASDLK"), + deviceDisplayName = "a device name", firstSeenTimestamp = 0, ) ) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt index 6ae4881926..4506dfe0c9 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.focused import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics 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.unit.dp import io.element.android.compound.theme.ElementTheme @@ -214,9 +215,7 @@ private fun ContentInitial( .padding(top = 24.dp), ) { VerificationUserProfileContent( - userId = request.details.senderProfile.userId, - displayName = request.details.senderProfile.displayName, - avatarUrl = request.details.senderProfile.avatarUrl, + user = request.details.senderProfile, ) } } @@ -292,3 +291,11 @@ internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificat state = state, ) } + +@Preview +@Composable +internal fun IncomingVerificationViewA11yPreview() = ElementPreview { + IncomingVerificationView( + state = anIncomingVerificationState(), + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt index da3471ddc1..14cd6733d9 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SessionDetailsView( - deviceName: String, + deviceName: String?, deviceId: DeviceId, signInFormattedTimestamp: String, modifier: Modifier = Modifier, @@ -61,7 +61,7 @@ fun SessionDetailsView( resourceId = CompoundDrawables.ic_compound_devices ) Text( - text = deviceName, + text = deviceName ?: deviceId.value, style = ElementTheme.typography.fontBodyMdMedium, color = ElementTheme.colors.textPrimary, ) @@ -87,9 +87,16 @@ fun SessionDetailsView( @PreviewsDayNight @Composable internal fun SessionDetailsViewPreview() = ElementPreview { - SessionDetailsView( - deviceName = "Element X Android", - deviceId = DeviceId("ILAKNDNASDLK"), - signInFormattedTimestamp = "12:34", - ) + Column { + SessionDetailsView( + deviceName = "Element X Android", + deviceId = DeviceId("ILAKNDNASDLK"), + signInFormattedTimestamp = "12:34", + ) + SessionDetailsView( + deviceName = null, + deviceId = DeviceId("ILAKNDNASDLK"), + signInFormattedTimestamp = "12:34", + ) + } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt index 4f6cfcf456..2e17b736f2 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,7 +24,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme 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.AvatarType 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.utils.CommonDrawables 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 fun VerificationUserProfileContent( - userId: UserId, - displayName: String?, - avatarUrl: String?, + user: MatrixUser, modifier: Modifier = Modifier, ) { - val avatarData = remember(userId, displayName, avatarUrl) { - AvatarData(id = userId.value, name = displayName, url = avatarUrl, size = AvatarSize.UserVerification) + val avatarData = remember(user) { + user.getAvatarData(AvatarSize.UserVerification) } - Row( modifier = modifier .fillMaxWidth() @@ -55,12 +58,20 @@ fun VerificationUserProfileContent( avatarData = avatarData, avatarType = AvatarType.User, ) - Spacer(modifier = Modifier.padding(12.dp)) + Spacer(modifier = Modifier.width(12.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) { - Text(text = userId.value, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary) + if (user.displayName.isNullOrEmpty().not()) { + 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 ) { VerificationUserProfileContent( - userId = UserId("@alice:example.com"), - displayName = "Alice", - avatarUrl = "https://example.com/avatar.png", + user = MatrixUser( + userId = UserId("@alice:example.com"), + displayName = "Alice", + avatarUrl = "https://example.com/avatar.png", + ) ) } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt index 06c4956ea9..2bf7116990 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -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.test.FakeDateFormatter 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.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState @@ -293,13 +294,14 @@ class IncomingVerificationPresenterTest { private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession( details = SessionVerificationRequestDetails( - senderProfile = SessionVerificationRequestDetails.SenderProfile( + senderProfile = MatrixUser( userId = A_USER_ID, - displayName = "a device name", + displayName = "a user name", avatarUrl = null, ), flowId = FlowId("flowId"), deviceId = A_DEVICE_ID, + deviceDisplayName = "a device name", firstSeenTimestamp = A_TIMESTAMP, ) ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt index 84aae82b66..2694191f89 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -244,7 +244,9 @@ interface BaseRoom : Closeable { suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow - /** + suspend fun threadRootIdForEvent(eventId: EventId): Result + + /** * Destroy the room and release all resources associated to it. */ fun destroy() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt index 060eb1f2db..1dddfadc5b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -7,6 +7,7 @@ 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.StateFlow import java.util.Optional @@ -17,9 +18,13 @@ interface SpaceRoomList { data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus } + val roomId: RoomId + val currentSpaceFlow: StateFlow> val spaceRoomsFlow: Flow> val paginationStatusFlow: StateFlow suspend fun paginate(): Result + + fun destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt index 897e17c611..edf8c6ff78 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt @@ -10,20 +10,14 @@ package io.element.android.libraries.matrix.api.verification import android.os.Parcelable 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.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.parcelize.Parcelize @Parcelize data class SessionVerificationRequestDetails( - val senderProfile: SenderProfile, + val senderProfile: MatrixUser, val flowId: FlowId, val deviceId: DeviceId, + val deviceDisplayName: String?, val firstSeenTimestamp: Long, -) : Parcelable { - @Parcelize - data class SenderProfile( - val userId: UserId, - val displayName: String?, - val avatarUrl: String?, - ) : Parcelable -} +) : Parcelable diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 79d17746ef..3f84621cb7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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.impl.encryption.RustEncryptionService 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.RustMediaPreviewService 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.sync.RustSyncService 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.util.SessionPathsProvider import io.element.android.libraries.matrix.impl.util.cancelAndDestroy @@ -403,7 +403,7 @@ class RustMatrixClient( override suspend fun getProfile(userId: UserId): Result = withContext(sessionDispatcher) { runCatchingExceptions { - innerClient.getProfile(userId.value).let(UserProfileMapper::map) + innerClient.getProfile(userId.value).map() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt similarity index 53% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt index f8f842c4be..6cc338610b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt @@ -5,17 +5,14 @@ * 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.user.MatrixUser import org.matrix.rustcomponents.sdk.UserProfile -object UserProfileMapper { - fun map(userProfile: UserProfile): MatrixUser = - MatrixUser( - userId = UserId(userProfile.userId), - displayName = userProfile.displayName, - avatarUrl = userProfile.avatarUrl, - ) -} +fun UserProfile.map() = MatrixUser( + userId = UserId(userId), + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt index 8697818f0a..6061ebd79c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt @@ -322,4 +322,12 @@ class RustBaseRoom( }) } } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.loadOrFetchEvent(eventId.value).use { + it.threadRootEventId()?.let(::ThreadId) + } + } + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt index dbcab85ab1..b94c3ffd1b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -8,26 +8,31 @@ package io.element.android.libraries.matrix.impl.spaces 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.SpaceRoomList import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState import java.util.Optional import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList class RustSpaceRoomList( + override val roomId: RoomId, private val innerProvider: suspend () -> InnerSpaceRoomList, - sessionCoroutineScope: CoroutineScope, + private val coroutineScope: CoroutineScope, spaceRoomMapper: SpaceRoomMapper, ) : SpaceRoomList { - private val inner = CompletableDeferred() + private val innerCompletable = CompletableDeferred() override val currentSpaceFlow = MutableStateFlow>(Optional.empty()) @@ -41,37 +46,45 @@ class RustSpaceRoomList( ) init { - sessionCoroutineScope.launch { - inner.complete(innerProvider()) - } - sessionCoroutineScope.launch { - inner.await().paginationStateFlow() + coroutineScope.launch { + val inner = innerProvider() + innerCompletable.complete(inner) + + inner.paginationStateFlow() .onEach { paginationStatus -> paginationStatusFlow.emit(paginationStatus.into()) } - .collect() - } + .launchIn(this) - sessionCoroutineScope.launch { - inner.await().spaceListUpdateFlow() + inner.spaceListUpdateFlow() .onEach { updates -> spaceListUpdateProcessor.postUpdates(updates) } - .collect() - } - sessionCoroutineScope.launch { - inner.await().spaceUpdateFlow() + .launchIn(this) + + inner.spaceUpdateFlow() .map { space -> space.map(spaceRoomMapper::map) } .onEach { space -> currentSpaceFlow.emit(space) } - .collect() + .launchIn(this) } } override suspend fun paginate(): Result { 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 } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 3dd905caf2..86d50a477c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -7,6 +7,7 @@ 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.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom @@ -54,9 +55,11 @@ class RustSpaceService( } override fun spaceRoomList(id: RoomId): SpaceRoomList { + val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this") return RustSpaceRoomList( + roomId = id, innerProvider = { innerSpaceService.spaceRoomList(id.value) }, - sessionCoroutineScope = sessionCoroutineScope, + coroutineScope = childCoroutineScope, spaceRoomMapper = spaceRoomMapper, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt index 5b2e8fa9a2..172f3c4cb0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt @@ -8,13 +8,16 @@ package io.element.android.libraries.matrix.impl.usersearch import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.impl.mapper.map import kotlinx.collections.immutable.toImmutableList import org.matrix.rustcomponents.sdk.SearchUsersResults object UserSearchResultMapper { fun map(result: SearchUsersResults): MatrixSearchUserResults { return MatrixSearchUserResults( - results = result.results.map(UserProfileMapper::map).toImmutableList(), + results = result.results + .map { userProfile -> userProfile.map() } + .toImmutableList(), limited = result.limited, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt index d2e15a3fac..cd398fbfc1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt @@ -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.verification.SessionVerificationRequestDetails 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.UserProfile as RustUserProfile fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( senderProfile = senderProfile.map(), flowId = FlowId(flowId), deviceId = DeviceId(deviceId), + deviceDisplayName = deviceDisplayName, firstSeenTimestamp = firstSeenTimestamp.toLong(), ) -fun RustUserProfile.map() = SessionVerificationRequestDetails.SenderProfile( - userId = UserId(userId), - displayName = displayName, - avatarUrl = avatarUrl, -) - fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming { val details = map() return if (currentUserId == details.senderProfile.userId) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt similarity index 78% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapperTest.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt index e04b0f26d5..ea2d971056 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt @@ -5,7 +5,7 @@ * 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 io.element.android.libraries.matrix.api.user.MatrixUser @@ -16,7 +16,7 @@ import org.junit.Test class UserProfileMapperTest { @Test 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")) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt index 007b6a669f..7a494ae8c3 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt @@ -11,9 +11,11 @@ package io.element.android.libraries.matrix.impl.spaces import app.cash.turbine.test 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.impl.fixtures.factories.aRustSpaceRoom 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.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -84,13 +86,15 @@ class RustSpaceRoomListTest { } private fun TestScope.createRustSpaceRoomList( + roomId: RoomId = A_ROOM_ID, innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(), innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList }, spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), ): RustSpaceRoomList { return RustSpaceRoomList( + roomId = roomId, innerProvider = innerProvider, - sessionCoroutineScope = backgroundScope, + coroutineScope = backgroundScope, spaceRoomMapper = spaceRoomMapper, ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt index 3ec9c3f870..31309beecf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -71,6 +71,7 @@ class FakeBaseRoom( private val forgetResult: () -> Result = { lambdaError() }, private val reportRoomResult: (String?) -> Result = { lambdaError() }, private val predecessorRoomResult: () -> PredecessorRoom? = { null }, + private val threadRootIdForEventResult: (EventId) -> Result = { lambdaError() }, ) : BaseRoom { private val _roomInfoFlow: MutableStateFlow = MutableStateFlow(initialRoomInfo) override val roomInfoFlow: StateFlow = _roomInfoFlow @@ -244,6 +245,10 @@ class FakeBaseRoom( fun givenUpdateMembersResult(result: () -> Unit) { updateMembersResult = result } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result { + return threadRootIdForEventResult(eventId) + } } fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt index d86e178fa9..6eac3dd0f4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt @@ -7,8 +7,10 @@ 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.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.simulateLongTask import kotlinx.coroutines.flow.Flow @@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.asStateFlow import java.util.Optional class FakeSpaceRoomList( + override val roomId: RoomId = A_ROOM_ID, initialSpaceFlowValue: SpaceRoom? = null, initialSpaceRoomsValue: List = emptyList(), initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, @@ -47,4 +50,8 @@ class FakeSpaceRoomList( override suspend fun paginate(): Result = simulateLongTask { paginateResult() } + + override fun destroy() { + // No op + } } diff --git a/libraries/ui-common/build.gradle.kts b/libraries/ui-common/build.gradle.kts new file mode 100644 index 0000000000..55ef7eaf49 --- /dev/null +++ b/libraries/ui-common/build.gradle.kts @@ -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) +} diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt new file mode 100644 index 0000000000..ab622b4b9d --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -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) +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistDiTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistDiTest.kt new file mode 100644 index 0000000000..093cfd4488 --- /dev/null +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistDiTest.kt @@ -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() + } + } +} diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png index f8e38d2a72..8208ba5369 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d345035d1ee105f7fa1702a3f953df99b0bfce2b0cf48cc92e6248d80690e4b5 -size 14582 +oid sha256:537781aec261b622c47345b48e8fbfd68e3cf0817bb36bf0e34e83769d6d6d21 +size 24337 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png index 63775e9552..7b095b8e0e 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15b1f5afddbd729f1ec01326280b11e00f2b7c84e816bd980eac96dc61db92f2 -size 13970 +oid sha256:9e5bf90db9ac805e4054c8d9c759181768a61f13bd30092bdf98fe0a962144f8 +size 23678 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png new file mode 100644 index 0000000000..87517cc357 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2eecd4b52a31208444d14b5017e42fc31df0391bb0ead311c01c9359a7e42f03 +size 74367 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en.png index 9b875b2275..61dcd44038 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17d9ed21a98412af097ec5f9a209ecfeb33de2c94a3aec2afc1443469106a4f9 -size 38451 +oid sha256:f0d95c8515dfe38a3de02fb68a035e41bedac03abadae44e371a85e6f32c9174 +size 38460 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en.png index 6f05eab9e5..3accdf3380 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78507911cefb2a6c7ab9b0ddf1a7a55bc4c13adadbd3a60075ca7eb89969a8b7 -size 32351 +oid sha256:74ecd0909c13895cd8d2e3827fb05fba98bd61f315735b281fd5502a9ae02bd2 +size 32354 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en.png index eb279e8ffd..7d7faefc03 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:541458c40f7b0e7c1dd18ba9211564fb053c264e965a7aaaede4e39c11a6420c -size 37505 +oid sha256:18e41a828de1e73f0909449c096bc7d9660e5b677075845657267db731b913ab +size 37597 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en.png index 26386330fc..43f97a426c 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f268e84b9d477429d817fc4b80ff99fbaff45434e236bae0ce05d656b4337675 -size 31620 +oid sha256:6242746ae0ba35d6e2e4a9c1d4c2a5e9433015eb7711767342afe4386edecfa1 +size 31701 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Day_0_en.png index 866ac11528..d5cbd5e217 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84870e3b5554f97ff8093286bbf246b74b3bfa787a8318304740f55492809721 -size 12397 +oid sha256:43836f421a5f9ab68e4a3f6b3430a6d3daeec9f737bc1b20940a3fc9597057c3 +size 12451 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Night_0_en.png index cd0a2208f4..ee9175a640 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.ui_VerificationUserProfileContent_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72e7183662d01486545e6702faf22dd867cc47ed72a50b8cd2c24a307cef58c9 -size 12408 +oid sha256:053fe3e5699e98280bd40008a26f92a64e34c0f3bda1a52434137745e1ed9cf2 +size 12360 diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png new file mode 100644 index 0000000000..1b6fb4bab8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 +size 3642 diff --git a/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png new file mode 100644 index 0000000000..d6fd8eeb70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.ui.common.nodes_EmptyView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd +size 3659