Skip to content

Mark room as fully read when user goes back to the room list. #2687

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.messages.impl.timeline

import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

interface MarkAsFullyRead {
operator fun invoke(roomId: RoomId)
}

@ContributesBinding(SessionScope::class)
class DefaultMarkAsFullyRead @Inject constructor(
private val matrixClient: MatrixClient,
) : MarkAsFullyRead {
override fun invoke(roomId: RoomId) {
matrixClient.sessionCoroutineScope.launch {
matrixClient.getRoom(roomId)?.use { room ->
room.markAsRead(receiptType = ReceiptType.FULLY_READ)
.onFailure {
Timber.e("Failed to mark room $roomId as fully read", it)
}

Check warning on line 33 in features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/MarkAsFullyRead.kt

View check run for this annotation

Codecov / codecov/patch

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/MarkAsFullyRead.kt#L32-L33

Added lines #L32 - L33 were not covered by tests
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
Expand Down Expand Up @@ -76,6 +77,7 @@ class TimelinePresenter @AssistedInject constructor(
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
Expand Down Expand Up @@ -177,6 +179,12 @@ class TimelinePresenter @AssistedInject constructor(
}
}

DisposableEffect(Unit) {
onDispose {
markAsFullyRead(room.roomId)
}
}

LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
markAsReadResult = { lambdaError() }
),
typingNoticeResult = { Result.success(Unit) },
)
assertThat(room.baseRoom.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(joinedRoom = room)
presenter.testWithLifecycleOwner {
runCurrent()
Expand Down Expand Up @@ -848,12 +848,12 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(joinedRoom = joinedRoom)
Expand Down Expand Up @@ -897,12 +897,12 @@ class MessagesPresenterTest {
val timeline = FakeTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
Expand Down Expand Up @@ -937,12 +937,12 @@ class MessagesPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
Expand Down Expand Up @@ -1096,12 +1096,12 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
Expand Down Expand Up @@ -1134,16 +1134,16 @@ class MessagesPresenterTest {
fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
},
sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
},
typingNoticeResult = { Result.success(Unit) },
)
val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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.
*/

@file:OptIn(ExperimentalCoroutinesApi::class)

package io.element.android.features.messages.impl.timeline

import io.element.android.libraries.matrix.api.timeline.ReceiptType
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.room.FakeBaseRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test

class DefaultMarkAsFullyReadTest {
@Test
fun `When room is not found, then no exception is thrown`() = runTest {
val markAsFullyRead = DefaultMarkAsFullyRead(
FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, null)
}
)
markAsFullyRead.invoke(A_ROOM_ID)
runCurrent()
}

@Test
fun `When room is found, the expected method is invoked`() = runTest {
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val baseRoom = FakeBaseRoom(
markAsReadResult = markAsReadResult
)
val markAsFullyRead = DefaultMarkAsFullyRead(
FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, baseRoom)
}
)
markAsFullyRead.invoke(A_ROOM_ID)
runCurrent()
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.FULLY_READ))
baseRoom.assertDestroyed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.messages.impl.timeline

import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError

class FakeMarkAsFullyRead(
private val invokeResult: (RoomId) -> Unit = { lambdaError() }
) : MarkAsFullyRead {
override fun invoke(roomId: RoomId) {
invokeResult(roomId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
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.UniqueId
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
Expand All @@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
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_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
Expand All @@ -58,6 +60,7 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -76,7 +79,9 @@ import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class)
class TimelinePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()

Expand Down Expand Up @@ -119,21 +124,42 @@ import kotlin.time.Duration.Companion.seconds
}
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
fun `present - on scroll finished mark a room as read if the first visible index is 0 - read private`() {
`present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled = false,
expectedReceiptType = ReceiptType.READ_PRIVATE,
)
}

@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0 - read`() {
`present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled = true,
expectedReceiptType = ReceiptType.READ,
)
}

private fun `present - on scroll finished mark a room as read if the first visible index is 0`(
isSendPublicReadReceiptsEnabled: Boolean,
expectedReceiptType: ReceiptType,
) = runTest(StandardTestDispatcher()) {
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
)
)
)
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) })
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
markAsReadResult = markAsReadResult,
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
Expand All @@ -145,11 +171,32 @@ import kotlin.time.Duration.Companion.seconds
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
assertThat(room.baseRoom.markAsReadCalls).isNotEmpty()
assert(markAsReadResult)
.isCalledOnce()
.with(value(expectedReceiptType))
cancelAndIgnoreRemainingEvents()
}
}

@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
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType ->
Expand Down Expand Up @@ -674,6 +721,7 @@ import kotlin.time.Duration.Companion.seconds
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
Expand All @@ -690,6 +738,7 @@ import kotlin.time.Duration.Companion.seconds
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
markAsFullyRead = markAsFullyRead,
)
}
}
Loading
Loading