Skip to content
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
1 change: 1 addition & 0 deletions features/home/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand All @@ -638,6 +663,7 @@ class RoomListPresenterTest {
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -64,6 +75,7 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase,
)
NotificationCategory(onPushHistoryClick)
MarkAllRoomsAsReadCategory(state)

if (state.isEnterpriseBuild) {
PreferenceCategory(title = "Theme") {
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading