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 <benoitm@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2025-09-30 11:18:13 +02:00 committed by GitHub
parent 43ad8743b8
commit 72c766d73a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 397 additions and 76 deletions

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

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

View file

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

View file

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