Add read all messages feature (#7049)
* Add read all messages feature: read all messages using Client.markAllRoomsAsRead() method * Move the 'mark all rooms as read' footer as part of the list item * Replace `showMarkAllRoomsAsReadConfirmation` and its associated event with `MarkAllRoomsAsRead(val needsConfirmation: Boolean)` and a `AsyncAction.Confirming` state --------- Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
committed by
GitHub
parent
c4f32db4be
commit
b8752152cf
@@ -57,6 +57,7 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(projects.features.preferences.impl)
|
||||
implementation(projects.features.reportroom.api)
|
||||
implementation(projects.features.rolesandpermissions.api)
|
||||
implementation(projects.libraries.previewutils)
|
||||
|
||||
+5
-18
@@ -41,6 +41,7 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteE
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
@@ -51,11 +52,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
@@ -65,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -79,12 +76,11 @@ class RoomListPresenter(
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val filtersPresenter: Presenter<RoomListFiltersState>,
|
||||
private val searchPresenter: Presenter<RoomListSearchState>,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
|
||||
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val markRoomAsRead: MarkRoomAsRead,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
private val announcementService: AnnouncementService,
|
||||
private val coldStartWatcher: AnalyticsColdStartWatcher,
|
||||
@@ -308,19 +304,10 @@ class RoomListPresenter(
|
||||
}
|
||||
|
||||
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
|
||||
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
room.setUnreadFlag(isUnread = false)
|
||||
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
|
||||
ReceiptType.READ
|
||||
} else {
|
||||
ReceiptType.READ_PRIVATE
|
||||
markRoomAsRead(roomId)
|
||||
.onSuccess {
|
||||
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
|
||||
}
|
||||
room.markAsRead(receiptType)
|
||||
.onSuccess {
|
||||
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch {
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.home.impl.roomlist
|
||||
|
||||
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
class FakeMarkRoomAsRead(
|
||||
private val invokeLambda: suspend (RoomId) -> Result<Unit> = { Result.success(Unit) },
|
||||
) : MarkRoomAsRead {
|
||||
val invokedRoomIds = mutableListOf<RoomId>()
|
||||
|
||||
override suspend fun invoke(roomId: RoomId): Result<Unit> {
|
||||
invokedRoomIds.add(roomId)
|
||||
return invokeLambda(roomId)
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -30,6 +30,7 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit
|
||||
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
|
||||
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
@@ -427,11 +428,17 @@ class RoomListPresenterTest {
|
||||
val notificationCleaner = FakeNotificationCleaner(
|
||||
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
|
||||
)
|
||||
val markRoomAsRead = createTestMarkRoomAsRead(
|
||||
client = matrixClient,
|
||||
notificationCleaner = notificationCleaner,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
val presenter = createRoomListPresenter(
|
||||
client = matrixClient,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
analyticsService = analyticsService,
|
||||
notificationCleaner = notificationCleaner,
|
||||
markRoomAsRead = markRoomAsRead,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
@@ -623,6 +630,24 @@ class RoomListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTestMarkRoomAsRead(
|
||||
client: MatrixClient,
|
||||
notificationCleaner: NotificationCleaner,
|
||||
sessionPreferencesStore: SessionPreferencesStore,
|
||||
): MarkRoomAsRead = FakeMarkRoomAsRead { roomId ->
|
||||
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
|
||||
val room = client.getRoom(roomId) ?: return@FakeMarkRoomAsRead Result.failure(IllegalStateException("Room not found"))
|
||||
room.use {
|
||||
it.setUnreadFlag(isUnread = false)
|
||||
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
|
||||
ReceiptType.READ
|
||||
} else {
|
||||
ReceiptType.READ_PRIVATE
|
||||
}
|
||||
it.markAsRead(receiptType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createRoomListPresenter(
|
||||
client: MatrixClient = FakeMatrixClient(),
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
@@ -638,6 +663,7 @@ class RoomListPresenterTest {
|
||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
markRoomAsRead: MarkRoomAsRead? = null,
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
leaveRoomPresenter = { leaveRoomState },
|
||||
@@ -654,14 +680,17 @@ class RoomListPresenterTest {
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
),
|
||||
searchPresenter = searchPresenter,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
filtersPresenter = filtersPresenter,
|
||||
spaceFiltersPresenter = spaceFiltersPresenter,
|
||||
analyticsService = analyticsService,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
|
||||
batteryOptimizationPresenter = { aBatteryOptimizationState() },
|
||||
notificationCleaner = notificationCleaner,
|
||||
markRoomAsRead = markRoomAsRead ?: createTestMarkRoomAsRead(
|
||||
client = client,
|
||||
notificationCleaner = notificationCleaner,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
),
|
||||
seenInvitesStore = seenInvitesStore,
|
||||
announcementService = announcementService,
|
||||
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
|
||||
+2
@@ -15,4 +15,6 @@ sealed interface DeveloperSettingsEvents {
|
||||
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
|
||||
data object ClearCache : DeveloperSettingsEvents
|
||||
data object VacuumStores : DeveloperSettingsEvents
|
||||
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvents
|
||||
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvents
|
||||
}
|
||||
|
||||
+26
-2
@@ -22,6 +22,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
||||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
|
||||
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
@@ -46,6 +47,7 @@ class DeveloperSettingsPresenter(
|
||||
private val vacuumStoresUseCase: VacuumStoresUseCase,
|
||||
private val databaseSizesUseCase: GetDatabaseSizesUseCase,
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
|
||||
) : Presenter<DeveloperSettingsState> {
|
||||
@Composable
|
||||
override fun present(): DeveloperSettingsState {
|
||||
@@ -58,6 +60,9 @@ class DeveloperSettingsPresenter(
|
||||
val clearCacheAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val markAllRoomsAsReadAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
var showColorPicker by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -88,6 +93,18 @@ class DeveloperSettingsPresenter(
|
||||
DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
|
||||
vacuumStoresUseCase()
|
||||
}
|
||||
is DeveloperSettingsEvents.MarkAllRoomsAsRead -> {
|
||||
if (event.needsConfirmation) {
|
||||
markAllRoomsAsReadAction.value = AsyncAction.ConfirmingNoParams
|
||||
} else {
|
||||
coroutineScope.markAllRoomsAsRead(
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation -> {
|
||||
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +114,7 @@ class DeveloperSettingsPresenter(
|
||||
cacheSize = cacheSize.value,
|
||||
databaseSizes = databaseSizes.value,
|
||||
clearCacheAction = clearCacheAction.value,
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
|
||||
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
eventSink = ::handleEvent,
|
||||
@@ -131,8 +149,14 @@ class DeveloperSettingsPresenter(
|
||||
}
|
||||
|
||||
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
|
||||
suspend { clearCacheUseCase() }.runCatchingUpdatingState(state = clearCacheAction)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.markAllRoomsAsRead(
|
||||
markAllRoomsAsReadAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
suspend {
|
||||
clearCacheUseCase()
|
||||
}.runCatchingUpdatingState(clearCacheAction)
|
||||
markAllRoomsAsRead().getOrThrow()
|
||||
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -18,9 +18,10 @@ data class DeveloperSettingsState(
|
||||
val cacheSize: AsyncData<String>,
|
||||
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
|
||||
val clearCacheAction: AsyncAction<Unit>,
|
||||
val markAllRoomsAsReadAction: AsyncAction<Unit>,
|
||||
val isEnterpriseBuild: Boolean,
|
||||
val showColorPicker: Boolean,
|
||||
val eventSink: (DeveloperSettingsEvents) -> Unit
|
||||
) {
|
||||
val showLoader = clearCacheAction is AsyncAction.Loading
|
||||
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
|
||||
}
|
||||
|
||||
+2
@@ -37,6 +37,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
|
||||
fun aDeveloperSettingsState(
|
||||
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
|
||||
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
isEnterpriseBuild: Boolean = false,
|
||||
showColorPicker: Boolean = false,
|
||||
eventSink: (DeveloperSettingsEvents) -> Unit = {},
|
||||
@@ -45,6 +46,7 @@ fun aDeveloperSettingsState(
|
||||
cacheSize = AsyncData.Success("1.2 MB"),
|
||||
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
|
||||
clearCacheAction = clearCacheAction,
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
||||
isEnterpriseBuild = isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
eventSink = eventSink,
|
||||
|
||||
+38
@@ -18,9 +18,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
@@ -45,6 +47,15 @@ fun DeveloperSettingsView(
|
||||
if (state.showLoader) {
|
||||
ProgressDialog()
|
||||
}
|
||||
if (state.markAllRoomsAsReadAction.isConfirming()) {
|
||||
ConfirmationDialog(
|
||||
title = "Are you sure you want to mark all the rooms as read?",
|
||||
content = "",
|
||||
submitText = stringResource(CommonStrings.action_yes),
|
||||
onSubmitClick = { state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = false)) },
|
||||
onDismiss = { state.eventSink(DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation) },
|
||||
)
|
||||
}
|
||||
BackHandler(
|
||||
enabled = !state.showLoader,
|
||||
onBack = onBackClick,
|
||||
@@ -64,6 +75,7 @@ fun DeveloperSettingsView(
|
||||
onOpenShowkase = onOpenShowkase,
|
||||
)
|
||||
NotificationCategory(onPushHistoryClick)
|
||||
MarkAllRoomsAsReadCategory(state)
|
||||
|
||||
if (state.isEnterpriseBuild) {
|
||||
PreferenceCategory(title = "Theme") {
|
||||
@@ -152,6 +164,32 @@ fun DeveloperSettingsView(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
|
||||
PreferenceCategory(title = "Room list") {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text("Mark all rooms as read")
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = """
|
||||
This will send a private read receipt and a read marker in every room you are part of.
|
||||
It's a long running operation that might get rate limited.
|
||||
It will run in the background but the app must be alive for it to finish.
|
||||
""".trimIndent(),
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
},
|
||||
enabled = !state.showLoader,
|
||||
onClick = {
|
||||
state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = true))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
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.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface MarkAllRoomsAsRead {
|
||||
suspend operator fun invoke(): Result<Unit>
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultMarkAllRoomsAsRead(
|
||||
private val client: MatrixClient,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : MarkAllRoomsAsRead {
|
||||
override suspend fun invoke(): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
client.markAllRoomsAsRead()
|
||||
.onSuccess {
|
||||
notificationCleaner.clearAllMessagesEvents(client.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
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.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface MarkRoomAsRead {
|
||||
suspend operator fun invoke(roomId: RoomId): Result<Unit>
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultMarkRoomAsRead(
|
||||
private val client: MatrixClient,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : MarkRoomAsRead {
|
||||
override suspend fun invoke(roomId: RoomId): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
|
||||
val room = client.getRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("Room not found"))
|
||||
room.use {
|
||||
it.setUnreadFlag(isUnread = false)
|
||||
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
|
||||
ReceiptType.READ
|
||||
} else {
|
||||
ReceiptType.READ_PRIVATE
|
||||
}
|
||||
it.markAsRead(receiptType)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -17,6 +17,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState
|
||||
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead
|
||||
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
@@ -127,6 +128,28 @@ class DeveloperSettingsPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - confirm mark all rooms as read`() = runTest {
|
||||
val markAllRoomsAsRead = FakeMarkAllRoomsAsRead()
|
||||
val presenter = createDeveloperSettingsPresenter(markAllRoomsAsRead = markAllRoomsAsRead)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = true))
|
||||
val stateWithConfirmation = awaitItem()
|
||||
assertThat(stateWithConfirmation.markAllRoomsAsReadAction.isConfirming()).isTrue()
|
||||
stateWithConfirmation.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = false))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.markAllRoomsAsReadAction.isConfirming()).isFalse()
|
||||
assertThat(state.markAllRoomsAsReadAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.markAllRoomsAsReadAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
assertThat(markAllRoomsAsRead.invokeCallCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - VacuumStores action invokes the VacuumStoresUseCase`() = runTest {
|
||||
var vacuumCalled = false
|
||||
@@ -151,6 +174,7 @@ class DeveloperSettingsPresenterTest {
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
|
||||
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
|
||||
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
|
||||
): DeveloperSettingsPresenter {
|
||||
return DeveloperSettingsPresenter(
|
||||
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
|
||||
@@ -161,6 +185,7 @@ class DeveloperSettingsPresenterTest {
|
||||
vacuumStoresUseCase = vacuumStoresUseCase,
|
||||
databaseSizesUseCase = databaseSizesUseCase,
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
markAllRoomsAsRead = markAllRoomsAsRead,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ class DeveloperSettingsViewTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h2200dp")
|
||||
@Config(qualifiers = "h2400dp")
|
||||
@Test
|
||||
fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
class FakeMarkAllRoomsAsRead(
|
||||
private val invokeLambda: suspend () -> Result<Unit> = { Result.success(Unit) },
|
||||
) : MarkAllRoomsAsRead {
|
||||
var invokeCallCount = 0
|
||||
private set
|
||||
|
||||
override suspend fun invoke(): Result<Unit> = simulateLongTask {
|
||||
invokeCallCount++
|
||||
invokeLambda()
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
class FakeMarkRoomAsRead(
|
||||
private val invokeLambda: suspend (RoomId) -> Result<Unit> = { Result.success(Unit) },
|
||||
) : MarkRoomAsRead {
|
||||
val invokedRoomIds = mutableListOf<RoomId>()
|
||||
|
||||
override suspend fun invoke(roomId: RoomId): Result<Unit> {
|
||||
invokedRoomIds.add(roomId)
|
||||
return invokeLambda(roomId)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MarkAllRoomsAsReadTest {
|
||||
@Test
|
||||
fun `invoke - delegates to client and clears all notifications`() = runTest {
|
||||
val markAllRoomsAsReadLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val clearAllMessagesEventsLambda = lambdaRecorder<SessionId, Unit> { }
|
||||
val markAllRoomsAsRead = DefaultMarkAllRoomsAsRead(
|
||||
client = FakeMatrixClient(
|
||||
markAllRoomsAsReadResult = markAllRoomsAsReadLambda,
|
||||
),
|
||||
notificationCleaner = FakeNotificationCleaner(
|
||||
clearAllMessagesEventsLambda = clearAllMessagesEventsLambda,
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
val result = markAllRoomsAsRead()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
markAllRoomsAsReadLambda.assertions().isCalledOnce()
|
||||
clearAllMessagesEventsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke - does not clear notifications when client fails`() = runTest {
|
||||
val clearAllMessagesEventsLambda = lambdaRecorder<SessionId, Unit> { }
|
||||
val markAllRoomsAsRead = DefaultMarkAllRoomsAsRead(
|
||||
client = FakeMatrixClient(
|
||||
markAllRoomsAsReadResult = { Result.failure(IllegalStateException("Failed")) },
|
||||
),
|
||||
notificationCleaner = FakeNotificationCleaner(
|
||||
clearAllMessagesEventsLambda = clearAllMessagesEventsLambda,
|
||||
),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
val result = markAllRoomsAsRead()
|
||||
|
||||
assertThat(result.isFailure).isTrue()
|
||||
clearAllMessagesEventsLambda.assertions().isNeverCalled()
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.preferences.impl.tasks
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
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.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MarkRoomAsReadTest {
|
||||
@Test
|
||||
fun `invoke - clears notifications, clears unread flag and sends public read receipt`() = runTest {
|
||||
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
||||
val room = FakeBaseRoom(markAsReadResult = markAsReadResult)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val clearMessagesForRoomLambda = lambdaRecorder { _: SessionId, _: RoomId -> }
|
||||
val notificationCleaner = FakeNotificationCleaner(clearMessagesForRoomLambda = clearMessagesForRoomLambda)
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore()
|
||||
val markRoomAsRead = DefaultMarkRoomAsRead(
|
||||
client = matrixClient,
|
||||
notificationCleaner = notificationCleaner,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
val result = markRoomAsRead(A_ROOM_ID)
|
||||
runCurrent()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
clearMessagesForRoomLambda.assertions().isCalledOnce()
|
||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
|
||||
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke - sends private read receipt when public receipts are disabled`() = runTest {
|
||||
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
||||
val room = FakeBaseRoom(markAsReadResult = markAsReadResult)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
|
||||
val markRoomAsRead = DefaultMarkRoomAsRead(
|
||||
client = matrixClient,
|
||||
notificationCleaner = FakeNotificationCleaner(clearMessagesForRoomLambda = { _, _ -> }),
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
val result = markRoomAsRead(A_ROOM_ID)
|
||||
runCurrent()
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
|
||||
}
|
||||
}
|
||||
+6
@@ -203,6 +203,12 @@ interface MatrixClient {
|
||||
*/
|
||||
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Mark all joined rooms as read by sending public, private and fully-read receipts
|
||||
* on each room's latest event. Per-room errors are logged and skipped by the SDK.
|
||||
*/
|
||||
suspend fun markAllRoomsAsRead(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Check if linking a new device using QrCode is supported by the server.
|
||||
*/
|
||||
|
||||
+6
@@ -800,6 +800,12 @@ class RustMatrixClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAllRoomsAsRead(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerClient.markAllRoomsAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun performDatabaseVacuum(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
Timber.d("Performing database vacuuming for session $sessionId...")
|
||||
|
||||
+5
@@ -117,6 +117,7 @@ class FakeMatrixClient(
|
||||
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
||||
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
|
||||
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
private val markAllRoomsAsReadResult: () -> Result<Unit> = { Result.success(Unit) },
|
||||
private val performDatabaseVacuumLambda: () -> Result<Unit> = { lambdaError() },
|
||||
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
||||
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
||||
@@ -373,6 +374,10 @@ class FakeMatrixClient(
|
||||
return markRoomAsFullyReadResult(roomId, eventId)
|
||||
}
|
||||
|
||||
override suspend fun markAllRoomsAsRead(): Result<Unit> {
|
||||
return markAllRoomsAsReadResult()
|
||||
}
|
||||
|
||||
override suspend fun performDatabaseVacuum(): Result<Unit> {
|
||||
return performDatabaseVacuumLambda()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user