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
commit dea808ce8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 351 additions and 102 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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