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:
parent
43ad8743b8
commit
72c766d73a
12 changed files with 397 additions and 76 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>())
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue