Add ability mark as unread / mark as read a room.

This commit is contained in:
Benoit Marty 2024-02-06 12:21:45 +01:00 committed by Benoit Marty
parent d06e5c23cb
commit 9d461a4917
21 changed files with 229 additions and 13 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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 = {}
)

View file

@ -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
}

View file

@ -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<RoomListState> {
@Composable
override fun present(): RoomListState {
val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val matrixUser: MutableState<MatrixUser?> = 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()
}
}
}

View file

@ -49,6 +49,7 @@ data class RoomListState(
val roomId: RoomId,
val roomName: String,
val isDm: Boolean,
val hasNewContent: Boolean,
) : ContextMenu
}
}

View file

@ -48,6 +48,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
roomId = RoomId("!aRoom:aDomain"),
roomName = "A nice room name",
isDm = false,
hasNewContent = false,
)
),
aRoomListState().copy(displayRecoveryKeyPrompt = true),

View file

@ -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)

View file

@ -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
}

View file

@ -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,

View file

@ -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),