Merge branch 'develop' into feature/fga/space_list_join_action

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

View file

@ -9,15 +9,15 @@ package io.element.android.x.di
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.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)

View file

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

View file

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

View file

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

View file

@ -36,10 +36,8 @@ import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.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<PlaceholderNode>(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<Plugin>,
) : Node(buildContext, plugins = plugins)
@Parcelize
private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room,

View file

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

View file

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

View file

@ -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<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) {
@ -83,7 +83,7 @@ class JoinedRoomLoadedFlowNode(
private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
override val graph = roomComponentFactory.create(inputs.room)
override val graph = roomGraphFactory.create(inputs.room)
init {
lifecycle.subscribe(

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.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,
)

View file

@ -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<Plugin>,
roomComponentFactory: RoomComponentFactory,
roomGraphFactory: RoomGraphFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@ -54,7 +54,7 @@ class ChangeRoomMemberRolesRootNode(
private val inputs = inputs<Inputs>()
override val graph = roomComponentFactory.create(inputs.joinedRoom)
override val graph = roomGraphFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return createNode<ChangeRolesNode>(

View file

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

View file

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

View file

@ -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<PlaceholderNode>(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<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

View file

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

View file

@ -14,7 +14,6 @@ import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.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<LockScreenSettingsFlowNode.NavTarget>(
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<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Unknown -> node(buildContext) { }
}
}

View file

@ -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<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}

View file

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

View file

@ -92,6 +92,14 @@ class MessagesNode(
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
data class Inputs(
val focusedEventId: EventId?,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val timelineController = TimelineController(room, room.liveTimeline)
private val 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<Callback>()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
private val inputs = inputs<Inputs>()
interface Callback : Plugin {
fun onRoomDetailsClick()
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>, 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<String>) {
override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
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,

View file

@ -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<Plugin>,
@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<String>) = Unit
override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>) {
val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList())
callbacks.forEach { it.onPermalinkClick(permalinkData) }
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
}
private fun onSendLocationClick() {
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,

View file

@ -14,6 +14,7 @@ import dev.zacsweers.metro.binding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.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<Unit> {
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<EventFocusResult> {
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
}

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.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) {

View file

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

View file

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

View file

@ -31,7 +31,9 @@ import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.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<String> -> }
val navigator = FakeMessagesNavigator(onNavigateToRoomLambda = openRoomLambda)
val presenter = createTimelinePresenter(
room = room,
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
advanceUntilIdle()
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
// The focus state will reset
assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.None)
// The room is opened again
openRoomLambda.assertions()
.isCalledOnce()
.with(
value(room.roomId),
value(AN_EVENT_ID),
value(emptyList<String>())
)
}
}
@Test
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
@ -754,7 +1004,7 @@ class TimelinePresenterTest {
canUserSendMessageResult = { _, _ -> Result.success(true) },
),
)
val onNavigateToRoomLambda = lambdaRecorder<RoomId, List<String>, Unit> { _, _ -> }
val onNavigateToRoomLambda = lambdaRecorder<RoomId, EventId?, List<String>, 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<String>())
)
}

View file

@ -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<Plugin>,
matrixClient: MatrixClient,
graphFactory: SpaceFlowGraph.Factory,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
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<SpaceEntryPoint.Callback>().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 -> {

View file

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

View file

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

View file

@ -15,20 +15,15 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.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<Plugin>,
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()

View file

@ -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<LeaveSpaceState> {
@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()

View file

@ -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<Plugin>,
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<Callback>().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(

View file

@ -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<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> {
@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) {

View file

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

View file

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

View file

@ -10,15 +10,11 @@
package io.element.android.features.space.impl.leave
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,
)
}
}

View file

@ -16,7 +16,6 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteS
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.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<Unit>> {
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<Unit>> {
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<Unit>> {
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<Unit>> {
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<Unit>> {
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<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter {
return SpacePresenter(
inputs = inputs,
client = client,
spaceRoomList = spaceRoomList,
seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.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,
)
)

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificat
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.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,
)
)

View file

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

View file

@ -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<Optional<SpaceRoom>>
val spaceRoomsFlow: Flow<List<SpaceRoom>>
val paginationStatusFlow: StateFlow<PaginationStatus>
suspend fun paginate(): Result<Unit>
fun destroy()
}

View file

@ -10,20 +10,14 @@ package io.element.android.libraries.matrix.api.verification
import android.os.Parcelable
import 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

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.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<MatrixUser> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getProfile(userId.value).let(UserProfileMapper::map)
innerClient.getProfile(userId.value).map()
}
}

View file

@ -5,17 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
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,
)

View file

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

View file

@ -8,26 +8,31 @@
package io.element.android.libraries.matrix.impl.spaces
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<InnerSpaceRoomList>()
private val innerCompletable = CompletableDeferred<InnerSpaceRoomList>()
override val currentSpaceFlow = MutableStateFlow<Optional<SpaceRoom>>(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<Unit> {
return runCatchingExceptions {
inner.await().paginate()
innerCompletable.await().paginate()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun destroy() {
Timber.d("Destroying SpaceRoomList $roomId")
coroutineScope.cancel()
try {
innerCompletable.getCompleted().destroy()
} catch (_: Exception) {
// Ignore, we just want to make sure it's completed
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.spaces
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,
)
}

View file

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

View file

@ -12,22 +12,17 @@ import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.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) {

View file

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

View file

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

View file

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

View file

@ -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<SpaceRoom> = emptyList(),
initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
@ -47,4 +50,8 @@ class FakeSpaceRoomList(
override suspend fun paginate(): Result<Unit> = simulateLongTask {
paginateResult()
}
override fun destroy() {
// No op
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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