From 72c766d73aaa6194b6831eda7f6b7e611a639094 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 30 Sep 2025 11:18:13 +0200 Subject: [PATCH] Follow permalinks to and from threads (#5414) * Implement navigation to event inside a thread when a permalink is used * Fix permalink navigation in threads to rooms * Fix navigating to a different thread from a permalink in an existing thread * Fix tests * Add missing tests for thread navigation * Reduce number of diff between ThreadedMessagesNode.kt and MessagesNode.kt * Navigate back to the room when a link to the current room is clicked in a thread. --------- Co-authored-by: Benoit Marty Co-authored-by: Benoit Marty --- .../messages/impl/MessagesFlowNode.kt | 4 + .../messages/impl/MessagesNavigator.kt | 2 +- .../features/messages/impl/MessagesNode.kt | 49 ++-- .../impl/threads/ThreadedMessagesNode.kt | 41 +-- .../impl/timeline/TimelineController.kt | 37 ++- .../impl/timeline/TimelinePresenter.kt | 45 ++- .../messages/impl/FakeMessagesNavigator.kt | 6 +- .../impl/timeline/TimelineControllerTest.kt | 14 +- .../impl/timeline/TimelinePresenterTest.kt | 258 +++++++++++++++++- .../libraries/matrix/api/room/BaseRoom.kt | 4 +- .../matrix/impl/room/RustBaseRoom.kt | 8 + .../matrix/test/room/FakeBaseRoom.kt | 5 + 12 files changed, 397 insertions(+), 76 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 74c477d7cf..af613e2520 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -459,6 +459,10 @@ class MessagesFlowNode( analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) elementCallEntryPoint.startCall(callType) } + + override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { + backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId)) + } } createNode(buildContext, listOf(inputs, callback)) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 250e271880..fc417fc029 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -21,6 +21,6 @@ interface MessagesNavigator { fun onReportContentClick(eventId: EventId, senderId: UserId) fun onEditPollClick(eventId: EventId) fun onPreviewAttachment(attachments: ImmutableList, inReplyToEventId: EventId?) - fun onNavigateToRoom(roomId: RoomId, serverNames: List) + fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index c934e5e90c..d4283d19d5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -92,6 +92,14 @@ class MessagesNode( private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { + private val callbacks = plugins() + + data class Inputs( + val focusedEventId: EventId?, + ) : NodeInputs + + private val inputs = inputs() + private val timelineController = TimelineController(room, room.liveTimeline) private val presenter = presenterFactory.create( navigator = this, @@ -99,18 +107,12 @@ class MessagesNode( timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), actionListPresenter = actionListPresenterFactory.create( postProcessor = TimelineItemActionPostProcessor.Default, - timelineMode = timelineController.mainTimelineMode() + timelineMode = timelineController.mainTimelineMode(), ), timelineController = timelineController, ) - private val callbacks = plugins() - - data class Inputs(val focusedEventId: EventId?) : NodeInputs - - private val inputs = inputs() interface Callback : Plugin { - fun onRoomDetailsClick() fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean fun onPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun onUserDataClick(userId: UserId) @@ -122,9 +124,10 @@ class MessagesNode( fun onCreatePollClick() fun onEditPollClick(eventId: EventId) fun onJoinCallClick(roomId: RoomId) + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun onRoomDetailsClick() fun onViewAllPinnedEvents() fun onViewKnockRequests() - fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } override fun onBuilt() { @@ -143,6 +146,14 @@ class MessagesNode( callbacks.forEach { it.onRoomDetailsClick() } } + private fun onViewAllPinnedMessagesClick() { + callbacks.forEach { it.onViewAllPinnedEvents() } + } + + private fun onViewKnockRequestsClick() { + callbacks.forEach { it.onViewKnockRequests() } + } + private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { // Note: cannot use `callbacks.all { it.onEventClick(event) }` because: // - if callbacks is empty, it will return true and we want to return false. @@ -223,11 +234,11 @@ class MessagesNode( callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) } } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) { + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { if (roomId == room.roomId) { displaySameRoomToast() } else { - val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), viaParameters = serverNames.toImmutableList()) + val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList()) callbacks.forEach { it.onPermalinkClick(permalinkData) } } } @@ -236,10 +247,6 @@ class MessagesNode( callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) } } - private fun onViewAllPinnedMessagesClick() { - callbacks.forEach { it.onViewAllPinnedEvents() } - } - private fun onSendLocationClick() { callbacks.forEach { it.onSendLocationClick() } } @@ -252,10 +259,6 @@ class MessagesNode( callbacks.forEach { it.onJoinCallClick(room.roomId) } } - private fun onViewKnockRequestsClick() { - callbacks.forEach { it.onViewKnockRequests() } - } - private fun displaySameRoomToast() { context.toast(CommonStrings.screen_room_permalink_same_room_android) } @@ -291,7 +294,15 @@ class MessagesNode( } }, onUserDataClick = this::onUserDataClick, - onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) }, + onLinkClick = { url, customTab -> + onLinkClick( + activity = activity, + darkTheme = isDark, + url = url, + eventSink = state.timelineState.eventSink, + customTab = customTab, + ) + }, onSendLocationClick = this::onSendLocationClick, onCreatePollClick = this::onCreatePollClick, onJoinCallClick = this::onJoinCallClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 1908427759..f732def95f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl.threads import android.app.Activity -import android.content.Context import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -44,19 +43,18 @@ import io.element.android.features.messages.impl.timeline.di.TimelineItemPresent import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.system.openUrlInExternalApp -import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -65,9 +63,9 @@ import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer -import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -77,7 +75,6 @@ import kotlinx.coroutines.runBlocking class ThreadedMessagesNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - @ApplicationContext private val context: Context, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom, private val analyticsService: AnalyticsService, @@ -125,6 +122,7 @@ class ThreadedMessagesNode( fun onCreatePollClick() fun onEditPollClick(eventId: EventId) fun onJoinCallClick(roomId: RoomId) + fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) } override fun onBuilt() { @@ -191,8 +189,11 @@ class ThreadedMessagesNode( if (eventId != null) { eventSink(TimelineEvents.FocusOnEvent(eventId)) } else { - // Click on the same room, ignore - displaySameRoomToast() + // Click on the same room, navigate up + // Note that it can not be enough to go back to the room if the thread has been opened + // following a permalink from another thread. In this case navigating up will go back + // to the previous thread. But this should not happen often. + navigateUp() } } else { callbacks.forEach { it.onPermalinkClick(roomLink) } @@ -219,7 +220,14 @@ class ThreadedMessagesNode( callbacks.forEach { it.onPreviewAttachments(attachments, inReplyToEventId) } } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) = Unit + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList()) + callbacks.forEach { it.onPermalinkClick(permalinkData) } + } + + override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { + callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) } + } private fun onSendLocationClick() { callbacks.forEach { it.onSendLocationClick() } @@ -233,13 +241,6 @@ class ThreadedMessagesNode( callbacks.forEach { it.onJoinCallClick(room.roomId) } } - private fun displaySameRoomToast() { - context.toast(CommonStrings.screen_room_permalink_same_room_android) - } - - override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { - } - @Composable override fun View(modifier: Modifier) { val activity = requireNotNull(LocalActivity.current) @@ -273,11 +274,11 @@ class ThreadedMessagesNode( onUserDataClick = this::onUserDataClick, onLinkClick = { url, customTab -> onLinkClick( - activity, - isDark, - url, - state.timelineState.eventSink, - customTab + activity = activity, + darkTheme = isDark, + url = url, + eventSink = state.timelineState.eventSink, + customTab = customTab, ) }, onSendLocationClick = this::onSendLocationClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index a0f4c2ce0e..779ebe984a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -14,6 +14,7 @@ import dev.zacsweers.metro.binding import io.element.android.features.messages.impl.timeline.di.LiveTimeline import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -74,21 +75,26 @@ class TimelineController( } } - suspend fun focusOnEvent(eventId: EventId): Result { - return room.createTimeline(CreateTimelineParams.Focused(eventId)) - .onFailure { - if (it is CancellationException) { - throw it - } - } - .map { newDetachedTimeline -> - detachedTimelineFlow.getAndUpdate { current -> - if (current.isPresent) { - current.get().close() + suspend fun focusOnEvent(eventId: EventId, threadRootId: ThreadId?): Result { + return if (threadRootId != null) { + Result.success(EventFocusResult.IsInThread(threadRootId)) + } else { + room.createTimeline(CreateTimelineParams.Focused(eventId)) + .onFailure { + if (it is CancellationException) { + throw it } - Optional.of(newDetachedTimeline) } - } + .map { newDetachedTimeline -> + detachedTimelineFlow.getAndUpdate { current -> + if (current.isPresent) { + current.get().close() + } + Optional.of(newDetachedTimeline) + } + EventFocusResult.FocusedOnLive + } + } } /** @@ -136,3 +142,8 @@ class TimelineController( return currentTimelineFlow } } + +sealed interface EventFocusResult { + data object FocusedOnLive : EventFocusResult + data class IsInThread(val threadId: ThreadId) : EventFocusResult +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index c1f096ccb7..f03a1e8903 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.isDm @@ -207,7 +208,7 @@ class TimelinePresenter( is TimelineEvents.NavigateToPredecessorOrSuccessorRoom -> { // Navigate to the predecessor or successor room val serverNames = calculateServerNamesForRoom(room) - navigator.onNavigateToRoom(event.roomId, serverNames) + navigator.onNavigateToRoom(event.roomId, null, serverNames) } is TimelineEvents.OpenThread -> { navigator.onOpenThread( @@ -257,13 +258,39 @@ class TimelinePresenter( } is FocusRequestState.Loading -> { val eventId = currentFocusRequestState.eventId - timelineController.focusOnEvent(eventId) - .onSuccess { - focusRequestState = FocusRequestState.Success(eventId = eventId) - } - .onFailure { - focusRequestState = FocusRequestState.Failure(it) - } + val threadId = room.threadRootIdForEvent(eventId).getOrElse { + focusRequestState = FocusRequestState.Failure(it) + return@LaunchedEffect + } + + if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) { + // We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room + focusRequestState = FocusRequestState.None + navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room)) + } else { + timelineController.focusOnEvent(eventId, threadId) + .onSuccess { result -> + when (result) { + is EventFocusResult.FocusedOnLive -> { + focusRequestState = FocusRequestState.Success(eventId = eventId) + } + is EventFocusResult.IsInThread -> { + val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId + if (currentThreadId == result.threadId) { + // It's the same thread, we just focus on the event + focusRequestState = FocusRequestState.Success(eventId = eventId) + } else { + focusRequestState = FocusRequestState.Success(eventId = result.threadId.asEventId()) + // It's part of a thread we're not in, let's open it in another timeline + navigator.onOpenThread(result.threadId, eventId) + } + } + } + } + .onFailure { + focusRequestState = FocusRequestState.Failure(it) + } + } } else -> Unit } @@ -341,7 +368,7 @@ class TimelinePresenter( newMostRecentItemId != prevMostRecentItemIdValue if (hasNewEvent) { - val newMostRecentEvent = newMostRecentItem as? TimelineItem.Event + val newMostRecentEvent = newMostRecentItem // Scroll to bottom if the new event is from me, even if sent from another device val fromMe = newMostRecentEvent?.isMine == true newEventState.value = if (fromMe) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 7320671694..bb59ca8551 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -22,7 +22,7 @@ class FakeMessagesNavigator( private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, - private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List) -> Unit = { _, _ -> lambdaError() }, + private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List) -> Unit = { _, _, _ -> lambdaError() }, private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, ) : MessagesNavigator { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { @@ -45,8 +45,8 @@ class FakeMessagesNavigator( onPreviewAttachmentLambda(attachments, inReplyToEventId) } - override fun onNavigateToRoom(roomId: RoomId, serverNames: List) { - onNavigateToRoomLambda(roomId, serverNames) + override fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + onNavigateToRoomLambda(roomId, eventId, serverNames) } override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 71d295ba8b..524cb3e1e8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -40,7 +40,7 @@ class TimelineControllerTest { assertThat(state).isEqualTo(liveTimeline) } assertThat(sut.isLive().first()).isTrue() - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -78,14 +78,14 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline1) } assertThat(detachedTimeline1.closeCounter).isEqualTo(0) assertThat(detachedTimeline2.closeCounter).isEqualTo(0) // Focus on another event should close the previous detached timeline - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline2) } @@ -124,7 +124,7 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -171,11 +171,11 @@ class TimelineControllerTest { ) val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) sut.activeTimelineFlow().test { - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } @@ -200,7 +200,7 @@ class TimelineControllerTest { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) } - sut.focusOnEvent(AN_EVENT_ID) + sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 8710af8bda..8da614f67e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -31,7 +31,9 @@ import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -44,6 +46,8 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID @@ -535,7 +539,10 @@ class TimelinePresenterTest { val room = FakeJoinedRoom( liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, - baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), ) val presenter = createTimelinePresenter( room = room, @@ -613,7 +620,10 @@ class TimelinePresenterTest { timelineItems = flowOf(emptyList()), ), createTimelineResult = { Result.failure(RuntimeException("An error")) }, - baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), ) ) moleculeFlow(RecompositionMode.Immediate) { @@ -639,6 +649,246 @@ class TimelinePresenterTest { } } + @Test + fun `present - focus on event in a thread opens the thread`() = runTest { + val threadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(threadId) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the thread root + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID.asEventId())) + + // The thread is opened + openThreadLambda.assertions() + .isCalledOnce() + .with( + value(threadId), + value(AN_EVENT_ID), + ) + } + } + + @Test + fun `present - focus on event in a thread when in the same thread just moves the focus`() = runTest { + val threadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(threadId), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(threadId) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the event directly since we are already in the thread + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID)) + + // The thread is not opened again + openThreadLambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - focus on event in a thread when in a different thread opens the new thread`() = runTest { + val currentThreadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(currentThreadId), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + // Use a different thread id + threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the event directly since we are already in the thread + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID_2.asEventId())) + + // The other thread is opened + openThreadLambda.assertions() + .isCalledOnce() + .with( + value(A_THREAD_ID_2), + value(AN_EVENT_ID), + ) + } + } + + @Test + fun `present - focus on event in a the room while in a thread of that room opens the room`() = runTest { + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(A_THREAD_ID), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + // The event is in the main timeline, not in a thread + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + val openRoomLambda = lambdaRecorder { _: RoomId, _: EventId?, _: List -> } + val navigator = FakeMessagesNavigator(onNavigateToRoomLambda = openRoomLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The focus state will reset + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.None) + + // The room is opened again + openRoomLambda.assertions() + .isCalledOnce() + .with( + value(room.roomId), + value(AN_EVENT_ID), + value(emptyList()) + ) + } + } + @Test fun `present - show shield hide shield`() = runTest { val presenter = createTimelinePresenter() @@ -754,7 +1004,7 @@ class TimelinePresenterTest { canUserSendMessageResult = { _, _ -> Result.success(true) }, ), ) - val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _ -> } + val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> } val navigator = FakeMessagesNavigator( onNavigateToRoomLambda = onNavigateToRoomLambda ) @@ -766,6 +1016,8 @@ class TimelinePresenterTest { .isCalledOnce() .with( value(A_ROOM_ID), + // No event id when navigating to a successor/predecessor room + value(null), value(emptyList()) ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt index 84aae82b66..2694191f89 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -244,7 +244,9 @@ interface BaseRoom : Closeable { suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow - /** + suspend fun threadRootIdForEvent(eventId: EventId): Result + + /** * Destroy the room and release all resources associated to it. */ fun destroy() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt index 8697818f0a..6061ebd79c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt @@ -322,4 +322,12 @@ class RustBaseRoom( }) } } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.loadOrFetchEvent(eventId.value).use { + it.threadRootEventId()?.let(::ThreadId) + } + } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt index 3ec9c3f870..31309beecf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -71,6 +71,7 @@ class FakeBaseRoom( private val forgetResult: () -> Result = { lambdaError() }, private val reportRoomResult: (String?) -> Result = { lambdaError() }, private val predecessorRoomResult: () -> PredecessorRoom? = { null }, + private val threadRootIdForEventResult: (EventId) -> Result = { lambdaError() }, ) : BaseRoom { private val _roomInfoFlow: MutableStateFlow = MutableStateFlow(initialRoomInfo) override val roomInfoFlow: StateFlow = _roomInfoFlow @@ -244,6 +245,10 @@ class FakeBaseRoom( fun givenUpdateMembersResult(result: () -> Unit) { updateMembersResult = result } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result { + return threadRootIdForEventResult(eventId) + } } fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues(