Merge pull request #6818 from element-hq/feature/bma/markAsUnreadInRoomDetails

Add mark as read / unread in room details
This commit is contained in:
Benoit Marty
2026-05-21 09:01:52 +02:00
committed by GitHub
66 changed files with 351 additions and 102 deletions
@@ -38,6 +38,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
data class Params(val initialElement: InitialTarget) : NodeInputs
interface Callback : Plugin {
fun onDone()
fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean = false)
@@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.testtags)
api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
@@ -69,6 +70,7 @@ dependencies {
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.call.test)
@@ -14,4 +14,6 @@ sealed interface RoomDetailsEvent {
data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
data object MarkAsRead : RoomDetailsEvent
data object MarkAsUnread : RoomDetailsEvent
}
@@ -176,6 +176,10 @@ class RoomDetailsFlowNode(
return when (navTarget) {
NavTarget.RoomDetails -> {
val roomDetailsCallback = object : RoomDetailsNode.Callback {
override fun navigateBack() {
callback.onDone()
}
override fun navigateToRoomMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
@@ -0,0 +1,12 @@
/*
* 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.roomdetails.impl
interface RoomDetailsNavigator {
fun onDone()
}
@@ -42,12 +42,13 @@ import io.element.android.libraries.androidutils.R as AndroidUtilsR
class RoomDetailsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomDetailsPresenter,
presenterFactory: RoomDetailsPresenter.Factory,
private val room: BaseRoom,
private val analyticsService: AnalyticsService,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), RoomDetailsNavigator {
interface Callback : Plugin {
fun navigateBack()
fun navigateToRoomMemberList()
fun navigateToInviteMembers()
fun navigateToRoomDetailsEdit()
@@ -65,6 +66,7 @@ class RoomDetailsNode(
fun navigateToSelectNewOwnersWhenLeaving()
}
private val presenter = presenterFactory.create(this)
private val callback: Callback = callback()
init {
@@ -144,4 +146,8 @@ class RoomDetailsNode(
}
)
}
override fun onDone() {
callback.navigateBack()
}
}
@@ -17,7 +17,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.api.knockRequestPermissions
@@ -44,19 +46,24 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class RoomDetailsPresenter(
@Assisted private val navigator: RoomDetailsNavigator,
private val client: MatrixClient,
private val room: JoinedRoom,
private val notificationSettingsService: NotificationSettingsService,
@@ -67,7 +74,16 @@ class RoomDetailsPresenter(
private val analyticsService: AnalyticsService,
private val clipboardHelper: ClipboardHelper,
private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore,
private val notificationCleaner: NotificationCleaner,
) : Presenter<RoomDetailsState> {
@AssistedFactory
interface Factory {
fun create(
navigator: RoomDetailsNavigator,
): RoomDetailsPresenter
}
@Composable
override fun present(): RoomDetailsState {
val scope = rememberCoroutineScope()
@@ -79,6 +95,14 @@ class RoomDetailsPresenter(
val roomTopic by remember { derivedStateOf { roomInfo.topic } }
val isFavorite by remember { derivedStateOf { roomInfo.isFavorite } }
val joinRule by remember { derivedStateOf { roomInfo.joinRule } }
val hasNewContent by remember {
derivedStateOf {
roomInfo.numUnreadMessages > 0 ||
roomInfo.numUnreadMentions > 0 ||
roomInfo.numUnreadNotifications > 0 ||
roomInfo.isMarkedUnread
}
}
val pinnedMessagesCount by remember { derivedStateOf { roomInfo.pinnedEventIds.size } }
@@ -145,6 +169,8 @@ class RoomDetailsPresenter(
clipboardHelper.copyPlainText(event.text)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
is RoomDetailsEvent.MarkAsRead -> scope.markAsRead()
is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread()
}
}
@@ -188,6 +214,7 @@ class RoomDetailsPresenter(
showDebugInfo = isDeveloperModeEnabled,
roomVersion = roomInfo.roomVersion,
roomHistoryVisibility = roomInfo.historyVisibility,
hasNewContent = hasNewContent,
eventSink = ::handleEvent,
)
}
@@ -241,4 +268,26 @@ class RoomDetailsPresenter(
analyticsService.captureInteraction(Interaction.Name.MobileRoomFavouriteToggle)
}
}
private fun CoroutineScope.markAsRead() = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, room.roomId)
room.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
room.markAsRead(receiptType)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
private fun CoroutineScope.markAsUnread() = launch {
room.setUnreadFlag(isUnread = true)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
navigator.onDone()
}
}
}
@@ -52,6 +52,7 @@ data class RoomDetailsState(
val showDebugInfo: Boolean,
val roomVersion: String?,
val roomHistoryVisibility: RoomHistoryVisibility,
val hasNewContent: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
) {
val roomBadges = buildList {
@@ -34,7 +34,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(displayAdminSettings = true),
aRoomDetailsState(roomTopic = RoomTopicState.Hidden, showDebugInfo = true),
aRoomDetailsState(roomTopic = RoomTopicState.Hidden, showDebugInfo = true, hasNewContent = true),
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState(isEncrypted = false),
aRoomDetailsState(roomAlias = null),
@@ -123,6 +123,7 @@ fun aRoomDetailsState(
isTombstoned: Boolean = false,
showDebugInfo: Boolean = false,
roomHistoryVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Shared,
hasNewContent: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@@ -154,6 +155,7 @@ fun aRoomDetailsState(
showDebugInfo = showDebugInfo,
roomVersion = "12",
roomHistoryVisibility = roomHistoryVisibility,
hasNewContent = hasNewContent,
eventSink = eventSink,
)
@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -188,6 +189,46 @@ fun RoomDetailsView(
)
}
PreferenceCategory {
if (state.hasNewContent) {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
)
},
onClick = {
state.eventSink(RoomDetailsEvent.MarkAsRead)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsRead())
),
trailingContent = ListItemContent.Custom {
Box(
modifier = modifier
.size(8.dp)
.clip(CircleShape)
.background(ElementTheme.colors.iconAccentPrimary)
)
},
)
} else {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
)
},
onClick = {
state.eventSink(RoomDetailsEvent.MarkAsUnread)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread())
),
)
}
}
PreferenceCategory {
if (state.roomNotificationSettings != null) {
NotificationItem(
@@ -132,6 +132,8 @@
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles &amp; permissions"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Add address"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Anyone in authorised spaces can join, but everyone else must request access."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Everyone must request access."</string>
@@ -68,6 +68,7 @@ class DefaultRoomDetailsEntryPointTest {
)
}
val callback = object : RoomDetailsEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) = lambdaError()
@@ -0,0 +1,16 @@
/*
* 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.roomdetails.impl
import io.element.android.tests.testutils.lambda.lambdaError
class FakeRoomDetailsNavigator(
private val onDoneResult: () -> Unit = { lambdaError() }
) : RoomDetailsNavigator {
override fun onDone() = onDoneResult()
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -28,7 +29,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.lambdaError
fun aRoom(
fun aFakeBaseRoom(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
displayName: String = A_ROOM_NAME,
@@ -49,6 +50,7 @@ fun aRoom(
getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
markAsReadResult: (ReceiptType) -> Result<Unit> = { lambdaError() },
) = FakeBaseRoom(
sessionId = sessionId,
roomId = roomId,
@@ -57,6 +59,7 @@ fun aRoom(
getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult,
markAsReadResult = markAsReadResult,
roomPermissions = roomPermissions,
initialRoomInfo = aRoomInfo(
name = displayName,
@@ -106,6 +109,7 @@ fun aJoinedRoom(
publishRoomAliasInRoomDirectoryResult: (RoomAlias) -> Result<Boolean> = { lambdaError() },
removeRoomAliasFromRoomDirectoryResult: (RoomAlias) -> Result<Boolean> = { lambdaError() },
setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
markAsReadResult: (ReceiptType) -> Result<Unit> = { lambdaError() },
) = FakeJoinedRoom(
roomNotificationSettingsService = notificationSettingsService,
setNameResult = setNameResult,
@@ -118,7 +122,7 @@ fun aJoinedRoom(
updateCanonicalAliasResult = updateCanonicalAliasResult,
publishRoomAliasInRoomDirectoryResult = publishRoomAliasInRoomDirectoryResult,
removeRoomAliasFromRoomDirectoryResult = removeRoomAliasFromRoomDirectoryResult,
baseRoom = aRoom(
baseRoom = aFakeBaseRoom(
sessionId = sessionId,
roomId = roomId,
roomPermissions = roomPermissions,
@@ -139,5 +143,6 @@ fun aJoinedRoom(
joinedMemberCount = joinedMemberCount,
activeMemberCount = activeMemberCount,
invitedMemberCount = invitedMemberCount,
markAsReadResult = markAsReadResult,
)
)
@@ -21,6 +21,8 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState
@@ -28,6 +30,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.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@@ -41,7 +44,11 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
@@ -79,7 +86,10 @@ class RoomDetailsPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(),
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@@ -96,6 +106,7 @@ class RoomDetailsPresenterTest {
}
}
return RoomDetailsPresenter(
navigator = navigator,
client = matrixClient,
room = room,
notificationSettingsService = matrixClient.notificationSettingsService,
@@ -106,6 +117,8 @@ class RoomDetailsPresenterTest {
analyticsService = analyticsService,
clipboardHelper = clipboardHelper,
appPreferencesStore = appPreferencesStore,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
)
}
@@ -598,6 +611,87 @@ class RoomDetailsPresenterTest {
}
}
@Test
fun `present - mark as read`() = runTest {
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { _ -> Result.success(Unit) }
val room = aJoinedRoom(
markAsReadResult = markAsReadResult,
)
val clearMessagesForRoomResult = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> Result.success(Unit) }
val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomResult,
)
val presenter = createRoomDetailsPresenter(
room = room,
notificationCleaner = notificationCleaner,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
with(awaitItem()) {
eventSink(RoomDetailsEvent.MarkAsRead)
}
assertThat(room.baseRoom.setUnreadFlagCalls).containsExactly(false)
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
clearMessagesForRoomResult.assertions().isCalledOnce().with(
value(room.sessionId),
value(room.roomId),
)
}
}
@Test
fun `present - mark as read - private`() = runTest {
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { _ -> Result.success(Unit) }
val room = aJoinedRoom(
markAsReadResult = markAsReadResult,
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(
isSendPublicReadReceiptsEnabled = false,
)
val clearMessagesForRoomResult = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> Result.success(Unit) }
val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomResult,
)
val presenter = createRoomDetailsPresenter(
room = room,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
with(awaitItem()) {
eventSink(RoomDetailsEvent.MarkAsRead)
}
assertThat(room.baseRoom.setUnreadFlagCalls).containsExactly(false)
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
clearMessagesForRoomResult.assertions().isCalledOnce().with(
value(room.sessionId),
value(room.roomId),
)
}
}
@Test
fun `present - mark as unread`() = runTest {
val room = aJoinedRoom()
val onDoneResult = lambdaRecorder<Unit> { }
val navigator = FakeRoomDetailsNavigator(
onDoneResult = onDoneResult
)
val presenter = createRoomDetailsPresenter(
room = room,
navigator = navigator,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
skipItems(1)
with(awaitItem()) {
eventSink(RoomDetailsEvent.MarkAsUnread)
}
onDoneResult.assertions().isCalledOnce()
assertThat(room.baseRoom.setUnreadFlagCalls).containsExactly(true)
}
}
private fun roomPermissions(
canInvite: Boolean = true,
canKick: Boolean = true,