Fix marking a room as read re-instantiates its timeline (#5628)
* Add `Timeline.markAsRead` to avoid reinstantiating the timeline using `Room.markAsRead` * Mark as read when exiting the room screen, destroy the timeline when fully closed * Ensure `MarkAsFullyReadAndExit` event can only be processed once * Fix `DelayedVisibility` not being displayed in previews
This commit is contained in:
parent
988815217a
commit
1f5f6896c6
24 changed files with 281 additions and 89 deletions
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||||
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
|
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
|
||||||
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
|
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -57,7 +58,9 @@ fun LoadingRoomNodeView(
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CircularProgressIndicator()
|
DelayedVisibility {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class JoinedRoomLoadedFlowNodeTest {
|
class JoinedRoomLoadedFlowNodeTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
@ -140,6 +143,7 @@ class JoinedRoomLoadedFlowNodeTest {
|
||||||
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
|
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
|
||||||
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
|
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
|
||||||
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
|
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
|
||||||
|
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||||
) = JoinedRoomLoadedFlowNode(
|
) = JoinedRoomLoadedFlowNode(
|
||||||
buildContext = BuildContext.root(savedStateMap = null),
|
buildContext = BuildContext.root(savedStateMap = null),
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
|
|
@ -148,9 +152,9 @@ class JoinedRoomLoadedFlowNodeTest {
|
||||||
spaceEntryPoint = spaceEntryPoint,
|
spaceEntryPoint = spaceEntryPoint,
|
||||||
forwardEntryPoint = forwardEntryPoint,
|
forwardEntryPoint = forwardEntryPoint,
|
||||||
appNavigationStateService = FakeAppNavigationStateService(),
|
appNavigationStateService = FakeAppNavigationStateService(),
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = backgroundScope,
|
||||||
roomGraphFactory = FakeRoomGraphFactory(),
|
roomGraphFactory = FakeRoomGraphFactory(),
|
||||||
matrixClient = FakeMatrixClient(),
|
matrixClient = matrixClient,
|
||||||
activeRoomsHolder = activeRoomsHolder,
|
activeRoomsHolder = activeRoomsHolder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ sealed interface MessagesEvents {
|
||||||
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
|
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
|
||||||
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
|
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
|
||||||
data object Dismiss : MessagesEvents
|
data object Dismiss : MessagesEvents
|
||||||
|
data object MarkAsFullyReadAndExit : MessagesEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class InviteDialogAction {
|
enum class InviteDialogAction {
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,5 @@ interface MessagesNavigator {
|
||||||
fun onPreviewAttachment(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
fun onPreviewAttachment(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||||
fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
fun onNavigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
||||||
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
|
fun onNavigateUp()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.LocalActivity
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
@ -263,6 +264,8 @@ class MessagesNode(
|
||||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNavigateUp() = navigateUp()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val activity = requireNotNull(LocalActivity.current)
|
val activity = requireNotNull(LocalActivity.current)
|
||||||
|
|
@ -271,6 +274,11 @@ class MessagesNode(
|
||||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||||
) {
|
) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
state.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
|
||||||
|
}
|
||||||
|
|
||||||
OnLifecycleEvent { _, event ->
|
OnLifecycleEvent { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
|
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
|
||||||
|
|
@ -279,7 +287,7 @@ class MessagesNode(
|
||||||
}
|
}
|
||||||
MessagesView(
|
MessagesView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = this::navigateUp,
|
onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) },
|
||||||
onRoomDetailsClick = this::onRoomDetailsClick,
|
onRoomDetailsClick = this::onRoomDetailsClick,
|
||||||
onEventContentClick = { isLive, event ->
|
onEventContentClick = { isLive, event ->
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import io.element.android.features.messages.impl.link.LinkState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
|
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
|
|
@ -65,6 +66,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.toThreadId
|
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||||
|
|
@ -93,6 +95,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class MessagesPresenter(
|
class MessagesPresenter(
|
||||||
|
|
@ -122,6 +125,8 @@ class MessagesPresenter(
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val addRecentEmoji: AddRecentEmoji,
|
private val addRecentEmoji: AddRecentEmoji,
|
||||||
|
private val markAsFullyRead: MarkAsFullyRead,
|
||||||
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -138,10 +143,13 @@ class MessagesPresenter(
|
||||||
timelineMode = timelineController.mainTimelineMode()
|
timelineMode = timelineController.mainTimelineMode()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val markingAsReadAndExiting = AtomicBoolean(false)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
htmlConverterProvider.Update()
|
htmlConverterProvider.Update()
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val composerState = composerPresenter.present()
|
val composerState = composerPresenter.present()
|
||||||
|
|
@ -239,6 +247,22 @@ class MessagesPresenter(
|
||||||
is MessagesEvents.OnUserClicked -> {
|
is MessagesEvents.OnUserClicked -> {
|
||||||
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
|
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
|
||||||
}
|
}
|
||||||
|
is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch {
|
||||||
|
if (!markingAsReadAndExiting.getAndSet(true)) {
|
||||||
|
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||||
|
Timber.w(it, "Failed to get latest event id to mark as fully read")
|
||||||
|
navigator.onNavigateUp()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
latestEventId?.let { eventId ->
|
||||||
|
sessionCoroutineScope.launch {
|
||||||
|
markAsFullyRead(room.roomId, eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigator.onNavigateUp()
|
||||||
|
markingAsReadAndExiting.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,8 @@ class ThreadedMessagesNode(
|
||||||
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
|
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNavigateUp() = navigateUp()
|
||||||
|
|
||||||
private fun onSendLocationClick() {
|
private fun onSendLocationClick() {
|
||||||
callbacks.forEach { it.onSendLocationClick() }
|
callbacks.forEach { it.onSendLocationClick() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,26 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
interface MarkAsFullyRead {
|
interface MarkAsFullyRead {
|
||||||
operator fun invoke(roomId: RoomId)
|
suspend operator fun invoke(roomId: RoomId, eventId: EventId): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(SessionScope::class)
|
||||||
class DefaultMarkAsFullyRead(
|
class DefaultMarkAsFullyRead(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : MarkAsFullyRead {
|
) : MarkAsFullyRead {
|
||||||
override fun invoke(roomId: RoomId) {
|
override suspend fun invoke(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
matrixClient.sessionCoroutineScope.launch {
|
matrixClient.markRoomAsFullyRead(roomId, eventId).onFailure {
|
||||||
matrixClient.getRoom(roomId)?.use { room ->
|
Timber.e(it, "Failed to mark room $roomId as fully read for event $eventId")
|
||||||
room.markAsRead(receiptType = ReceiptType.FULLY_READ)
|
|
||||||
.onFailure {
|
|
||||||
Timber.e("Failed to mark room $roomId as fully read", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -85,7 +84,6 @@ class TimelinePresenter(
|
||||||
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
||||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||||
private val markAsFullyRead: MarkAsFullyRead,
|
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -219,12 +217,6 @@ class TimelinePresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
markAsFullyRead(room.roomId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
timelineItemsFactory.timelineItems
|
timelineItemsFactory.timelineItems
|
||||||
.onEach { newTimelineItems ->
|
.onEach { newTimelineItems ->
|
||||||
|
|
@ -388,7 +380,7 @@ class TimelinePresenter(
|
||||||
) = launch(dispatchers.computation) {
|
) = launch(dispatchers.computation) {
|
||||||
// If we are at the bottom of timeline, we mark the room as read.
|
// If we are at the bottom of timeline, we mark the room as read.
|
||||||
if (firstVisibleIndex == 0) {
|
if (firstVisibleIndex == 0) {
|
||||||
room.markAsRead(receiptType = readReceiptType)
|
room.liveTimeline.markAsRead(receiptType = readReceiptType)
|
||||||
} else {
|
} else {
|
||||||
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
||||||
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class FakeMessagesNavigator(
|
||||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||||
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, 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() },
|
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||||
|
private val onNavigateUpLambda: () -> Unit = { lambdaError() },
|
||||||
) : MessagesNavigator {
|
) : MessagesNavigator {
|
||||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
||||||
|
|
@ -52,4 +53,8 @@ class FakeMessagesNavigator(
|
||||||
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
onOpenThreadLambda(threadRootId, focusedEventId)
|
onOpenThreadLambda(threadRootId, focusedEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNavigateUp() {
|
||||||
|
onNavigateUpLambda()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
||||||
|
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
||||||
|
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||||
|
|
@ -178,7 +180,11 @@ class MessagesPresenterTest {
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val presenter = createMessagesPresenter(joinedRoom = room, coroutineDispatchers = coroutineDispatchers)
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = timeline,
|
||||||
|
joinedRoom = room,
|
||||||
|
coroutineDispatchers = coroutineDispatchers
|
||||||
|
)
|
||||||
presenter.testWithLifecycleOwner {
|
presenter.testWithLifecycleOwner {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -220,7 +226,11 @@ class MessagesPresenterTest {
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val presenter = createMessagesPresenter(joinedRoom = room, coroutineDispatchers = coroutineDispatchers)
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = timeline,
|
||||||
|
joinedRoom = room,
|
||||||
|
coroutineDispatchers = coroutineDispatchers
|
||||||
|
)
|
||||||
presenter.testWithLifecycleOwner {
|
presenter.testWithLifecycleOwner {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
|
initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId()))
|
||||||
|
|
@ -509,6 +519,7 @@ class MessagesPresenterTest {
|
||||||
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
|
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
|
||||||
liveTimeline.redactEventLambda = redactEventLambda
|
liveTimeline.redactEventLambda = redactEventLambda
|
||||||
val presenter = createMessagesPresenter(
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = liveTimeline,
|
||||||
joinedRoom = joinedRoom,
|
joinedRoom = joinedRoom,
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
)
|
)
|
||||||
|
|
@ -920,6 +931,7 @@ class MessagesPresenterTest {
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val presenter = createMessagesPresenter(
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = timeline,
|
||||||
joinedRoom = room,
|
joinedRoom = room,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
)
|
)
|
||||||
|
|
@ -962,7 +974,11 @@ class MessagesPresenterTest {
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
)
|
)
|
||||||
val presenter = createMessagesPresenter(joinedRoom = room, analyticsService = analyticsService)
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = timeline,
|
||||||
|
joinedRoom = room,
|
||||||
|
analyticsService = analyticsService
|
||||||
|
)
|
||||||
presenter.testWithLifecycleOwner {
|
presenter.testWithLifecycleOwner {
|
||||||
val messageEvent = aMessageEvent(
|
val messageEvent = aMessageEvent(
|
||||||
content = aTimelineItemTextContent()
|
content = aTimelineItemTextContent()
|
||||||
|
|
@ -1236,8 +1252,57 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle MarkAsFullyReadAndExit marks the room as fully read and navigates up`() = runTest {
|
||||||
|
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||||
|
val markAsFullyReadUseCase = FakeMarkAsFullyRead(markAsFullyReadRecorder)
|
||||||
|
val onNavigateUpRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val navigator = FakeMessagesNavigator(onNavigateUpLambda = onNavigateUpRecorder)
|
||||||
|
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }),
|
||||||
|
markAsFullyRead = markAsFullyReadUseCase,
|
||||||
|
navigator = navigator,
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
markAsFullyReadRecorder.assertions().isCalledOnce()
|
||||||
|
onNavigateUpRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle MarkAsFullyReadAndExit still navigates up if marking as read fails`() = runTest {
|
||||||
|
val markAsFullyReadUseCase = FakeMarkAsFullyRead { _, _ -> error("boom") }
|
||||||
|
val onNavigateUpRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val navigator = FakeMessagesNavigator(onNavigateUpLambda = onNavigateUpRecorder)
|
||||||
|
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }),
|
||||||
|
markAsFullyRead = markAsFullyReadUseCase,
|
||||||
|
navigator = navigator,
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
onNavigateUpRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createMessagesPresenter(
|
private fun TestScope.createMessagesPresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
timeline: Timeline = FakeTimeline(),
|
||||||
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
|
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
|
|
@ -1248,10 +1313,9 @@ class MessagesPresenterTest {
|
||||||
).apply {
|
).apply {
|
||||||
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
||||||
},
|
},
|
||||||
liveTimeline = FakeTimeline(),
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) },
|
typingNoticeResult = { Result.success(Unit) },
|
||||||
),
|
),
|
||||||
timeline: Timeline = joinedRoom.liveTimeline,
|
|
||||||
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
|
|
@ -1270,6 +1334,7 @@ class MessagesPresenterTest {
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
actionListEventSink: (ActionListEvents) -> Unit = {},
|
actionListEventSink: (ActionListEvents) -> Unit = {},
|
||||||
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()),
|
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()),
|
||||||
|
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
|
|
@ -1298,6 +1363,8 @@ class MessagesPresenterTest {
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
addRecentEmoji = addRecentEmoji,
|
addRecentEmoji = addRecentEmoji,
|
||||||
|
markAsFullyRead = markAsFullyRead,
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -22,34 +25,30 @@ import org.junit.Test
|
||||||
|
|
||||||
class DefaultMarkAsFullyReadTest {
|
class DefaultMarkAsFullyReadTest {
|
||||||
@Test
|
@Test
|
||||||
fun `When room is not found, then no exception is thrown`() = runTest {
|
fun `When marking as read fails, no exception is thrown`() = runTest {
|
||||||
val markAsFullyRead = DefaultMarkAsFullyRead(
|
val markAsFullyRead = DefaultMarkAsFullyRead(
|
||||||
FakeMatrixClient(
|
matrixClient = FakeMatrixClient(
|
||||||
sessionCoroutineScope = backgroundScope,
|
markRoomAsFullyReadResult = { _, _ -> Result.failure(IllegalStateException("Room not found")) },
|
||||||
).apply {
|
).apply {
|
||||||
givenGetRoomResult(A_ROOM_ID, null)
|
givenGetRoomResult(A_ROOM_ID, null)
|
||||||
}
|
},
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
)
|
)
|
||||||
markAsFullyRead.invoke(A_ROOM_ID)
|
assertThat(markAsFullyRead.invoke(A_ROOM_ID, AN_EVENT_ID).isFailure).isTrue()
|
||||||
runCurrent()
|
runCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `When room is found, the expected method is invoked`() = runTest {
|
fun `When marking as read is successful, the expected method is invoked`() = runTest {
|
||||||
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
val markAsFullyReadResult = lambdaRecorder<RoomId, EventId, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||||
val baseRoom = FakeBaseRoom(
|
|
||||||
markAsReadResult = markAsReadResult
|
|
||||||
)
|
|
||||||
val markAsFullyRead = DefaultMarkAsFullyRead(
|
val markAsFullyRead = DefaultMarkAsFullyRead(
|
||||||
FakeMatrixClient(
|
matrixClient = FakeMatrixClient(
|
||||||
sessionCoroutineScope = backgroundScope,
|
markRoomAsFullyReadResult = markAsFullyReadResult,
|
||||||
).apply {
|
),
|
||||||
givenGetRoomResult(A_ROOM_ID, baseRoom)
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
}
|
|
||||||
)
|
)
|
||||||
markAsFullyRead.invoke(A_ROOM_ID)
|
assertThat(markAsFullyRead.invoke(A_ROOM_ID, AN_EVENT_ID).isSuccess).isTrue()
|
||||||
runCurrent()
|
runCurrent()
|
||||||
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.FULLY_READ))
|
markAsFullyReadResult.assertions().isCalledOnce().with(value(A_ROOM_ID), value(AN_EVENT_ID))
|
||||||
baseRoom.assertDestroyed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,15 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
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.RoomId
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakeMarkAsFullyRead(
|
class FakeMarkAsFullyRead(
|
||||||
private val invokeResult: (RoomId) -> Unit = { lambdaError() }
|
private val invokeResult: (RoomId, EventId) -> Unit = { _, _ -> lambdaError() },
|
||||||
) : MarkAsFullyRead {
|
) : MarkAsFullyRead {
|
||||||
override fun invoke(roomId: RoomId) {
|
override suspend fun invoke(roomId: RoomId, eventId: EventId): Result<Unit> {
|
||||||
invokeResult(roomId)
|
return runCatchingExceptions { invokeResult(roomId, eventId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,19 +151,21 @@ class TimelinePresenterTest {
|
||||||
isSendPublicReadReceiptsEnabled: Boolean,
|
isSendPublicReadReceiptsEnabled: Boolean,
|
||||||
expectedReceiptType: ReceiptType,
|
expectedReceiptType: ReceiptType,
|
||||||
) = runTest(StandardTestDispatcher()) {
|
) = runTest(StandardTestDispatcher()) {
|
||||||
|
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val sendReadReceiptLambda = lambdaRecorder<EventId, ReceiptType, Result<Unit>> { _, _ -> Result.success(Unit) }
|
||||||
val timeline = FakeTimeline(
|
val timeline = FakeTimeline(
|
||||||
timelineItems = flowOf(
|
timelineItems = flowOf(
|
||||||
listOf(
|
listOf(
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
markAsReadResult = markAsReadResult,
|
||||||
|
sendReadReceiptLambda = sendReadReceiptLambda,
|
||||||
)
|
)
|
||||||
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
|
||||||
val room = FakeJoinedRoom(
|
val room = FakeJoinedRoom(
|
||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
baseRoom = FakeBaseRoom(
|
baseRoom = FakeBaseRoom(
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||||
markAsReadResult = markAsReadResult,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
|
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
|
||||||
|
|
@ -185,25 +187,6 @@ class TimelinePresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - once presenter is disposed, room is marked as fully read`() = runTest {
|
|
||||||
val invokeResult = lambdaRecorder<RoomId, Unit> { }
|
|
||||||
val presenter = createTimelinePresenter(
|
|
||||||
room = FakeJoinedRoom(
|
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
|
||||||
)
|
|
||||||
),
|
|
||||||
markAsFullyRead = FakeMarkAsFullyRead(
|
|
||||||
invokeResult = invokeResult,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
presenter.test {
|
|
||||||
awaitFirstItem()
|
|
||||||
}
|
|
||||||
invokeResult.assertions().isCalledOnce().with(value(A_ROOM_ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
|
||||||
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
|
||||||
|
|
@ -258,10 +241,10 @@ class TimelinePresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
).apply {
|
markAsReadResult = { Result.success(Unit) },
|
||||||
this.sendReadReceiptLambda = sendReadReceiptsLambda
|
sendReadReceiptLambda = sendReadReceiptsLambda,
|
||||||
}
|
)
|
||||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
|
|
@ -349,7 +332,10 @@ class TimelinePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - covers newEventState scenarios`() = runTest {
|
fun `present - covers newEventState scenarios`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
markAsReadResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -1039,7 +1025,6 @@ class TimelinePresenterTest {
|
||||||
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
|
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
): TimelinePresenter {
|
): TimelinePresenter {
|
||||||
return TimelinePresenter(
|
return TimelinePresenter(
|
||||||
|
|
@ -1057,7 +1042,6 @@ class TimelinePresenterTest {
|
||||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||||
typingNotificationPresenter = { aTypingNotificationState() },
|
typingNotificationPresenter = { aTypingNotificationState() },
|
||||||
roomCallStatePresenter = { aStandByCallState() },
|
roomCallStatePresenter = { aStandByCallState() },
|
||||||
markAsFullyRead = markAsFullyRead,
|
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.utils
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.movableContentOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the content of [block] after a delay of [duration].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DelayedVisibility(
|
||||||
|
duration: Duration = 300.milliseconds,
|
||||||
|
block: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Technically this shouldn't be needed because `LocalInspectionMode` won't change, but let's make the linter happy
|
||||||
|
val movableBlock = remember { movableContentOf { block() } }
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
// Just allow the contents to be displayed in the previews/screenshot tests
|
||||||
|
movableBlock()
|
||||||
|
} else {
|
||||||
|
var shouldDisplay by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(duration)
|
||||||
|
shouldDisplay = true
|
||||||
|
}
|
||||||
|
AnimatedVisibility(shouldDisplay) {
|
||||||
|
movableBlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.api
|
||||||
|
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -34,6 +35,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
@ -183,6 +185,14 @@ interface MatrixClient {
|
||||||
* Adds an emoji to the list of recent emoji reactions for this account.
|
* Adds an emoji to the list of recent emoji reactions for this account.
|
||||||
*/
|
*/
|
||||||
suspend fun addRecentEmoji(emoji: String): Result<Unit>
|
suspend fun addRecentEmoji(emoji: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the room with the provided [roomId] as read, sending a fully read receipt for [eventId].
|
||||||
|
*
|
||||||
|
* This method should be used with caution as providing the [eventId] ourselves can result in incorrect read receipts.
|
||||||
|
* Use [Timeline.markAsRead] instead when possible.
|
||||||
|
*/
|
||||||
|
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ sealed class QrLoginException : Exception() {
|
||||||
data object OidcMetadataInvalid : QrLoginException()
|
data object OidcMetadataInvalid : QrLoginException()
|
||||||
data object SlidingSyncNotAvailable : QrLoginException()
|
data object SlidingSyncNotAvailable : QrLoginException()
|
||||||
data object OtherDeviceNotSignedIn : QrLoginException()
|
data object OtherDeviceNotSignedIn : QrLoginException()
|
||||||
data object Unknown : QrLoginException()
|
|
||||||
data object CheckCodeAlreadySent : QrLoginException()
|
data object CheckCodeAlreadySent : QrLoginException()
|
||||||
data object CheckCodeCannotBeSent : QrLoginException()
|
data object CheckCodeCannotBeSent : QrLoginException()
|
||||||
|
data object Unknown : QrLoginException()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsV
|
||||||
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -180,6 +181,10 @@ interface BaseRoom : Closeable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the room as read by trying to attach an unthreaded read receipt to the latest room event.
|
* Mark the room as read by trying to attach an unthreaded read receipt to the latest room event.
|
||||||
|
*
|
||||||
|
* Note this will instantiate a new timeline, which is an expensive operation.
|
||||||
|
* Prefer using [Timeline.markAsRead] instead when possible.
|
||||||
|
*
|
||||||
* @param receiptType The type of receipt to send.
|
* @param receiptType The type of receipt to send.
|
||||||
*/
|
*/
|
||||||
suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
|
suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ interface Timeline : AutoCloseable {
|
||||||
val mode: Mode
|
val mode: Mode
|
||||||
val membershipChangeEventReceived: Flow<Unit>
|
val membershipChangeEventReceived: Flow<Unit>
|
||||||
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
|
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
|
||||||
|
suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
|
||||||
suspend fun paginate(direction: PaginationDirection): Result<Boolean>
|
suspend fun paginate(direction: PaginationDirection): Result<Boolean>
|
||||||
|
|
||||||
val backwardPaginationStatus: StateFlow<PaginationStatus>
|
val backwardPaginationStatus: StateFlow<PaginationStatus>
|
||||||
|
|
@ -227,4 +228,9 @@ interface Timeline : AutoCloseable {
|
||||||
* pinned
|
* pinned
|
||||||
*/
|
*/
|
||||||
suspend fun unpinEvent(eventId: EventId): Result<Boolean>
|
suspend fun unpinEvent(eventId: EventId): Result<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest event id of the timeline.
|
||||||
|
*/
|
||||||
|
suspend fun getLatestEventId(): Result<EventId?>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
|
@ -713,6 +714,13 @@ class RustMatrixClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")
|
||||||
|
room.markAsFullyReadUnchecked(eventId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getCacheSize(
|
private suspend fun getCacheSize(
|
||||||
includeCryptoDb: Boolean = false,
|
includeCryptoDb: Boolean = false,
|
||||||
): Long = withContext(sessionDispatcher) {
|
): Long = withContext(sessionDispatcher) {
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,7 @@ class JoinedRustRoom(
|
||||||
override fun destroy() {
|
override fun destroy() {
|
||||||
baseRoom.destroy()
|
baseRoom.destroy()
|
||||||
liveInnerTimeline.destroy()
|
liveInnerTimeline.destroy()
|
||||||
|
Timber.d("Room $roomId destroyed")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun InnerTimeline.map(
|
private fun InnerTimeline.map(
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,12 @@ class RustTimeline(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
inner.markAsRead(receiptType.toRustReceiptType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
|
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
|
||||||
when (direction) {
|
when (direction) {
|
||||||
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
|
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
|
||||||
|
|
@ -586,6 +592,12 @@ class RustTimeline(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLatestEventId(): Result<EventId?> = withContext(dispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
inner.latestEventId()?.let(::EventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
inner.fetchDetailsForEvent(eventId.value)
|
inner.fetchDetailsForEvent(eventId.value)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.test
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
|
@ -101,6 +102,7 @@ class FakeMatrixClient(
|
||||||
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
||||||
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
||||||
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
|
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
|
||||||
|
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
var setDisplayNameCalled: Boolean = false
|
var setDisplayNameCalled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -344,4 +346,8 @@ class FakeMatrixClient(
|
||||||
override suspend fun getRecentEmojis(): Result<List<String>> {
|
override suspend fun getRecentEmojis(): Result<List<String>> {
|
||||||
return getRecentEmojisLambda()
|
return getRecentEmojisLambda()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> {
|
||||||
|
return markRoomAsFullyReadResult(roomId, eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ class FakeTimeline(
|
||||||
override val membershipChangeEventReceived: Flow<Unit> = MutableSharedFlow(),
|
override val membershipChangeEventReceived: Flow<Unit> = MutableSharedFlow(),
|
||||||
private val cancelSendResult: (TransactionId) -> Result<Unit> = { lambdaError() },
|
private val cancelSendResult: (TransactionId) -> Result<Unit> = { lambdaError() },
|
||||||
override val mode: Timeline.Mode = Timeline.Mode.Live,
|
override val mode: Timeline.Mode = Timeline.Mode.Live,
|
||||||
|
private val markAsReadResult: (ReceiptType) -> Result<Unit> = { lambdaError() },
|
||||||
|
private val getLatestEventIdResult: () -> Result<EventId?> = { lambdaError() },
|
||||||
|
var sendReadReceiptLambda: (
|
||||||
|
eventId: EventId,
|
||||||
|
receiptType: ReceiptType,
|
||||||
|
) -> Result<Unit> = { _, _ ->
|
||||||
|
lambdaError()
|
||||||
|
}
|
||||||
) : Timeline {
|
) : Timeline {
|
||||||
var sendMessageLambda: (
|
var sendMessageLambda: (
|
||||||
body: String,
|
body: String,
|
||||||
|
|
@ -397,18 +405,15 @@ class FakeTimeline(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sendReadReceiptLambda: (
|
|
||||||
eventId: EventId,
|
|
||||||
receiptType: ReceiptType,
|
|
||||||
) -> Result<Unit> = { _, _ ->
|
|
||||||
lambdaError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendReadReceipt(
|
override suspend fun sendReadReceipt(
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
receiptType: ReceiptType,
|
receiptType: ReceiptType,
|
||||||
): Result<Unit> = sendReadReceiptLambda(eventId, receiptType)
|
): Result<Unit> = sendReadReceiptLambda(eventId, receiptType)
|
||||||
|
|
||||||
|
override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> {
|
||||||
|
return markAsReadResult(receiptType)
|
||||||
|
}
|
||||||
|
|
||||||
var paginateLambda: (direction: Timeline.PaginationDirection) -> Result<Boolean> = {
|
var paginateLambda: (direction: Timeline.PaginationDirection) -> Result<Boolean> = {
|
||||||
Result.success(false)
|
Result.success(false)
|
||||||
}
|
}
|
||||||
|
|
@ -431,6 +436,10 @@ class FakeTimeline(
|
||||||
return unpinEventLambda(eventId)
|
return unpinEventLambda(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLatestEventId(): Result<EventId?> {
|
||||||
|
return getLatestEventIdResult()
|
||||||
|
}
|
||||||
|
|
||||||
var closeCounter = 0
|
var closeCounter = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue