diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 5f21e5b348..1b264ac1f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -155,6 +155,14 @@ class MessagesPresenter @AssistedInject constructor( mutableStateOf(false) } + LaunchedEffect(Unit) { + // Mark the room as read on entering but don't send read receipts + // as those will be handled by the timeline. + withContext(dispatchers.io) { + room.markAsRead(null) + } + } + LaunchedEffect(syncUpdateFlow.value) { withContext(dispatchers.io) { canJoinCall = room.canUserJoinCall(room.sessionId).getOrDefault(false) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 9dd9ff5d88..fe7e07e6f9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -96,7 +96,9 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -129,6 +131,21 @@ class MessagesPresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - check that the room is marked as read`() = runTest { + val room = FakeMatrixRoom() + assertThat(room.markAsReadCalls).isEmpty() + val presenter = createMessagesPresenter(matrixRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + runCurrent() + assertThat(room.markAsReadCalls).isEqualTo(listOf(null)) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest { val room = FakeMatrixRoom().apply { diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 4e415fd2a2..02fbcc058a 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(projects.libraries.eventformatter.api) implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink) + implementation(projects.libraries.preferences.api) implementation(projects.features.invitelist.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.leaveroom.api) @@ -71,6 +72,7 @@ dependencies { testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.permissions.noop) + testImplementation(projects.libraries.preferences.test) testImplementation(projects.features.invitelist.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.tests.testutils) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt index 1d2cf2c910..f605b71a10 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt @@ -49,6 +49,14 @@ fun RoomListContextMenu( ) { RoomListModalBottomSheetContent( contextMenu = contextMenu, + onRoomMarkReadClicked = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.MarkAsRead(it)) + }, + onRoomMarkUnreadClicked = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.MarkAsUnread(it)) + }, onRoomSettingsClicked = { eventSink(RoomListEvents.HideContextMenu) onRoomSettingsClicked(it) @@ -64,6 +72,8 @@ fun RoomListContextMenu( @Composable private fun RoomListModalBottomSheetContent( contextMenu: RoomListState.ContextMenu.Shown, + onRoomMarkReadClicked: (roomId: RoomId) -> Unit, + onRoomMarkUnreadClicked: (roomId: RoomId) -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit, onLeaveRoomClicked: (roomId: RoomId) -> Unit, ) { @@ -78,6 +88,36 @@ private fun RoomListModalBottomSheetContent( ) } ) + ListItem( + headlineContent = { + Text( + text = stringResource( + id = if (contextMenu.hasNewContent) { + R.string.screen_roomlist_mark_as_read + } else { + R.string.screen_roomlist_mark_as_unread + } + ), + style = MaterialTheme.typography.bodyLarge, + ) + }, + modifier = Modifier.clickable { + if (contextMenu.hasNewContent) { + onRoomMarkReadClicked(contextMenu.roomId) + } else { + onRoomMarkUnreadClicked(contextMenu.roomId) + } + }, + /* TODO Design + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector( + CompoundIcons.Settings, + contentDescription = stringResource(id = CommonStrings.common_settings) + ) + ), + */ + style = ListItemStyle.Primary, + ) ListItem( headlineContent = { Text( @@ -96,11 +136,13 @@ private fun RoomListModalBottomSheetContent( ) ListItem( headlineContent = { - val leaveText = stringResource(id = if (contextMenu.isDm) { - CommonStrings.action_leave_conversation - } else { - CommonStrings.action_leave_room - }) + val leaveText = stringResource( + id = if (contextMenu.isDm) { + CommonStrings.action_leave_conversation + } else { + CommonStrings.action_leave_room + } + ) Text(text = leaveText) }, modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) }, @@ -126,7 +168,10 @@ internal fun RoomListModalBottomSheetContentPreview() = ElementPreview { roomId = RoomId(value = "!aRoom:aDomain"), roomName = "aRoom", isDm = false, + hasNewContent = true, ), + onRoomMarkReadClicked = {}, + onRoomMarkUnreadClicked = {}, onRoomSettingsClicked = {}, onLeaveRoomClicked = {} ) @@ -140,7 +185,10 @@ internal fun RoomListModalBottomSheetContentForDmPreview() = ElementPreview { roomId = RoomId(value = "!aRoom:aDomain"), roomName = "aRoom", isDm = true, + hasNewContent = false, ), + onRoomMarkReadClicked = {}, + onRoomMarkUnreadClicked = {}, onRoomSettingsClicked = {}, onLeaveRoomClicked = {} ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index d54bc604cf..6831f63451 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -28,4 +28,6 @@ sealed interface RoomListEvents { data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents data object HideContextMenu : RoomListEvents data class LeaveRoom(val roomId: RoomId) : RoomListEvents + data class MarkAsRead(val roomId: RoomId) : RoomListEvents + data class MarkAsUnread(val roomId: RoomId) : RoomListEvents } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index f25ebcec20..2a06914aa2 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -25,12 +25,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter @@ -44,10 +46,12 @@ import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -65,9 +69,11 @@ class RoomListPresenter @Inject constructor( private val featureFlagService: FeatureFlagService, private val indicatorService: IndicatorService, private val migrationScreenPresenter: MigrationScreenPresenter, + private val sessionPreferencesStore: SessionPreferencesStore, ) : Presenter { @Composable override fun present(): RoomListState { + val coroutineScope = rememberCoroutineScope() val leaveRoomState = leaveRoomPresenter.present() val matrixUser: MutableState = rememberSaveable { mutableStateOf(null) @@ -129,10 +135,22 @@ class RoomListPresenter @Inject constructor( roomId = event.roomListRoomSummary.roomId, roomName = event.roomListRoomSummary.name, isDm = event.roomListRoomSummary.isDm, + hasNewContent = event.roomListRoomSummary.hasNewContent ) } is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) + is RoomListEvents.MarkAsRead -> coroutineScope.launch { + val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + client.getRoom(event.roomId)?.markAsRead(receiptType) + } + is RoomListEvents.MarkAsUnread -> coroutineScope.launch { + client.getRoom(event.roomId)?.markAsUnread() + } } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 13ba030720..10dd17037a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -49,6 +49,7 @@ data class RoomListState( val roomId: RoomId, val roomName: String, val isDm: Boolean, + val hasNewContent: Boolean, ) : ContextMenu } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 7fda3a180a..f56359c5d1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -48,6 +48,7 @@ open class RoomListStateProvider : PreviewParameterProvider { roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name", isDm = false, + hasNewContent = false, ) ), aRoomListState().copy(displayRecoveryKeyPrompt = true), diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt index 2d47be35a3..d3c7c43148 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt @@ -45,6 +45,7 @@ class RoomListRoomSummaryFactory @Inject constructor( numberOfUnreadMessages = 0, numberOfUnreadMentions = 0, numberOfUnreadNotifications = 0, + isMarkedUnread = false, userDefinedNotificationMode = null, hasRoomCall = false, isDm = false, @@ -73,6 +74,7 @@ class RoomListRoomSummaryFactory @Inject constructor( numberOfUnreadMessages = roomSummary.details.numUnreadMessages, numberOfUnreadMentions = roomSummary.details.numUnreadMentions, numberOfUnreadNotifications = roomSummary.details.numUnreadNotifications, + isMarkedUnread = roomSummary.details.isMarkedUnread, timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), lastMessage = roomSummary.details.lastMessage?.let { message -> roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt index 26cd9e2d32..960a7c23bb 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt @@ -29,6 +29,7 @@ data class RoomListRoomSummary( val numberOfUnreadMessages: Int, val numberOfUnreadMentions: Int, val numberOfUnreadNotifications: Int, + val isMarkedUnread: Boolean, val timestamp: String?, val lastMessage: CharSequence?, val avatarData: AvatarData, @@ -37,10 +38,12 @@ data class RoomListRoomSummary( val hasRoomCall: Boolean, val isDm: Boolean, ) { - val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && - (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) + val isHighlighted = (userDefinedNotificationMode != RoomNotificationMode.MUTE && + (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0)) || + isMarkedUnread val hasNewContent = numberOfUnreadMessages > 0 || numberOfUnreadMentions > 0 || - numberOfUnreadNotifications > 0 + numberOfUnreadNotifications > 0 || + isMarkedUnread } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt index e53e1f9072..9b80edecb0 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt @@ -89,6 +89,7 @@ internal fun aRoomListRoomSummary( numberOfUnreadMessages: Int = 0, numberOfUnreadMentions: Int = 0, numberOfUnreadNotifications: Int = 0, + isMarkedUnread: Boolean = false, lastMessage: String? = "Last message", timestamp: String? = lastMessage?.let { "88:88" }, isPlaceholder: Boolean = false, @@ -103,6 +104,7 @@ internal fun aRoomListRoomSummary( numberOfUnreadMessages = numberOfUnreadMessages, numberOfUnreadMentions = numberOfUnreadMentions, numberOfUnreadNotifications = numberOfUnreadNotifications, + isMarkedUnread = isMarkedUnread, timestamp = timestamp, lastMessage = lastMessage, avatarData = avatarData, diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 10730f0c90..d2543828fb 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -25,6 +25,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource @@ -41,12 +42,14 @@ import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_AVATAR_URL @@ -59,6 +62,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -326,8 +330,14 @@ class RoomListPresenterTests { initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) val shownState = awaitItem() - assertThat(shownState.contextMenu) - .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false)) + assertThat(shownState.contextMenu).isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + hasNewContent = true, + ) + ) scope.cancel() } } @@ -346,8 +356,14 @@ class RoomListPresenterTests { initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) val shownState = awaitItem() - assertThat(shownState.contextMenu) - .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name, false)) + assertThat(shownState.contextMenu).isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + hasNewContent = true, + ) + ) shownState.eventSink(RoomListEvents.HideContextMenu) val hiddenState = awaitItem() @@ -430,6 +446,41 @@ class RoomListPresenterTests { } } + @Test + fun `present - check that the room is marked as read with correct RR and as unread`() = runTest { + val room = FakeMatrixRoom() + val sessionPreferencesStore = InMemorySessionPreferencesStore() + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + client = matrixClient, + coroutineScope = scope, + sessionPreferencesStore = sessionPreferencesStore, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(room.markAsReadCalls).isEmpty() + assertThat(room.markAsUnreadReadCallCount).isEqualTo(0) + initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) + assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ)) + assertThat(room.markAsUnreadReadCallCount).isEqualTo(0) + initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID)) + assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ)) + assertThat(room.markAsUnreadReadCallCount).isEqualTo(1) + // Test again with private read receipts + sessionPreferencesStore.setSendPublicReadReceipts(false) + initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) + assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE)) + assertThat(room.markAsUnreadReadCallCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + scope.cancel() + } + } + private fun TestScope.createRoomListPresenter( client: MatrixClient = FakeMatrixClient(), sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), @@ -442,6 +493,7 @@ class RoomListPresenterTests { }, roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), encryptionService: EncryptionService = FakeEncryptionService(), + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), coroutineScope: CoroutineScope, migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( matrixClient = client, @@ -472,6 +524,7 @@ class RoomListPresenterTests { featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), ), migrationScreenPresenter = migrationScreenPresenter, + sessionPreferencesStore = sessionPreferencesStore, ) } @@ -484,6 +537,7 @@ private val aRoomListRoomSummary = RoomListRoomSummary( numberOfUnreadMentions = 1, numberOfUnreadMessages = 2, numberOfUnreadNotifications = 0, + isMarkedUnread = false, timestamp = A_FORMATTED_DATE, lastMessage = "", avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 550bb7012a..051e0e419e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import kotlinx.coroutines.flow.Flow @@ -150,6 +151,17 @@ interface MatrixRoom : Closeable { suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result + /** + * Reverts a previously set unread flag, and eventually send a Read Receipt. + * @param receiptType The type of receipt to send. If null, no Read Receipt will be sent. + */ + suspend fun markAsRead(receiptType: ReceiptType?): Result + + /** + * Sets a flag on the room to indicate that the user has explicitly marked it as unread. + */ + suspend fun markAsUnread(): Result + /** * Share a location message in the room. * diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt index cc584f08bb..b8e6dbd84f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt @@ -43,6 +43,7 @@ data class RoomSummaryDetails( val numUnreadMessages: Int, val numUnreadMentions: Int, val numUnreadNotifications: Int, + val isMarkedUnread: Boolean, val inviter: RoomMember?, val userDefinedNotificationMode: RoomNotificationMode?, val hasRoomCall: Boolean, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 3fc7d65468..36b6cde936 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.impl.core.toProgressWatcher @@ -51,6 +52,7 @@ import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline +import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl @@ -423,6 +425,22 @@ class RustMatrixRoom( } } + override suspend fun markAsRead(receiptType: ReceiptType?): Result = withContext(roomDispatcher) { + runCatching { + if (receiptType != null) { + innerRoom.markAsReadAndSendReadReceipt(receiptType.toRustReceiptType()) + } else { + innerRoom.markAsRead() + } + } + } + + override suspend fun markAsUnread(): Result = withContext(roomDispatcher) { + runCatching { + innerRoom.markAsUnread() + } + } + override suspend fun sendLocation( body: String, geoUri: String, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt index 1b3e900f17..cd3c86871b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt @@ -38,6 +38,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto numUnreadMentions = roomInfo.numUnreadMentions.toInt(), numUnreadMessages = roomInfo.numUnreadMessages.toInt(), numUnreadNotifications = roomInfo.numUnreadNotifications.toInt(), + isMarkedUnread = roomInfo.isMarkedUnread, lastMessage = latestRoomMessage, inviter = roomInfo.inviter?.let(RoomMemberMapper::map), userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index c33a6d43b2..fdd0dbb6cb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings @@ -374,6 +375,20 @@ class FakeMatrixRoom( return reportContentResult } + val markAsReadCalls = mutableListOf() + override suspend fun markAsRead(receiptType: ReceiptType?): Result { + markAsReadCalls.add(receiptType) + return Result.success(Unit) + } + + var markAsUnreadReadCallCount = 0 + private set + + override suspend fun markAsUnread(): Result { + markAsUnreadReadCallCount++ + return Result.success(Unit) + } + override suspend fun sendLocation( body: String, geoUri: String, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 2bd04894c0..bc3ce1d807 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -62,6 +62,7 @@ fun aRoomSummaryDetails( numUnreadMentions: Int = 0, numUnreadMessages: Int = 0, numUnreadNotifications: Int = 0, + isMarkedUnread: Boolean = false, notificationMode: RoomNotificationMode? = null, inviter: RoomMember? = null, canonicalAlias: String? = null, @@ -76,6 +77,7 @@ fun aRoomSummaryDetails( numUnreadMentions = numUnreadMentions, numUnreadMessages = numUnreadMessages, numUnreadNotifications = numUnreadNotifications, + isMarkedUnread = isMarkedUnread, userDefinedNotificationMode = notificationMode, inviter = inviter, canonicalAlias = canonicalAlias, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt index a4d9a479c2..bebe82dca2 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -116,6 +116,7 @@ fun aRoomSummaryDetails( numUnreadMentions: Int = 0, numUnreadMessages: Int = 0, numUnreadNotifications: Int = 0, + isMarkedUnread: Boolean = false, ) = RoomSummaryDetails( roomId = roomId, name = name, @@ -130,4 +131,5 @@ fun aRoomSummaryDetails( numUnreadMentions = numUnreadMentions, numUnreadMessages = numUnreadMessages, numUnreadNotifications = numUnreadNotifications, + isMarkedUnread = isMarkedUnread, ) diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index edf2527572..f9bfc63932 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(projects.libraries.network) implementation(projects.libraries.dateformatter.impl) implementation(projects.libraries.eventformatter.impl) + implementation(projects.libraries.preferences.impl) implementation(projects.libraries.indicator.impl) implementation(projects.features.invitelist.impl) implementation(projects.features.roomlist.impl) diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index f87119293d..218dfdd697 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.preferences.impl.store.DefaultSessionPreferencesStore import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -108,7 +109,12 @@ class RoomListScreen( migrationScreenPresenter = MigrationScreenPresenter( matrixClient = matrixClient, migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE)) - ) + ), + sessionPreferencesStore = DefaultSessionPreferencesStore( + context = context, + sessionId = matrixClient.sessionId, + sessionCoroutineScope = Singleton.appScope + ), ) @Composable