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

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