From 51c06b4becae8df102aa919d6e0c1f8baa855e94 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 17 Sep 2026 14:49:03 +0200 Subject: [PATCH 01/47] Timeline : initial multi-select state --- .../messages/impl/MessagesPresenter.kt | 4 + .../features/messages/impl/MessagesView.kt | 80 ++++++---- .../impl/actionlist/ActionListPresenter.kt | 9 ++ .../actionlist/model/TimelineItemAction.kt | 1 + .../model/TimelineItemActionComparator.kt | 1 + .../pinned/list/PinnedMessagesListView.kt | 2 + .../messages/impl/timeline/SelectionState.kt | 24 +++ .../messages/impl/timeline/TimelineEvent.kt | 4 + .../impl/timeline/TimelinePresenter.kt | 59 ++++++++ .../messages/impl/timeline/TimelineState.kt | 12 ++ .../impl/timeline/TimelineStateProvider.kt | 2 + .../messages/impl/timeline/TimelineView.kt | 60 +++++++- .../timeline/components/SelectionIndicator.kt | 54 +++++++ .../components/TimelineItemEventRow.kt | 10 +- .../TimelineItemGroupedEventsRow.kt | 1 + .../timeline/components/TimelineItemRow.kt | 142 ++++++++++++++++-- .../components/TimelineItemSelectionData.kt | 24 +++ .../timeline/model/TimelineItemSelection.kt | 34 +++++ .../impl/topbars/SelectionModeTopBar.kt | 59 ++++++++ .../impl/src/main/res/values/temporary.xml | 15 ++ .../actionlist/ActionListPresenterTest.kt | 38 +++++ .../impl/timeline/TimelinePresenterTest.kt | 77 ++++++++++ .../model/TimelineItemSelectionTest.kt | 47 ++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 + 24 files changed, 726 insertions(+), 40 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt create mode 100644 features/messages/impl/src/main/res/values/temporary.xml create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6a019d865b..bcbfc35255 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -402,6 +402,10 @@ class MessagesPresenter( TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) + TimelineItemAction.Select -> { + val eventId = targetEvent.eventId ?: return@launch + timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 16ca023d50..c4bcf552b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -89,12 +91,15 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent +import io.element.android.features.messages.impl.timeline.isSelectionModeActive import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.selectedCount import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView import io.element.android.features.messages.impl.topbars.MessagesViewTopBar +import io.element.android.features.messages.impl.topbars.SelectionModeTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog @@ -177,6 +182,14 @@ fun MessagesView( block() } + // Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard + // triggers the IME hide animation, keeping it in sync with the composer collapse. + LaunchedEffect(state.timelineState.isSelectionModeActive) { + if (state.timelineState.isSelectionModeActive) { + localView.hideKeyboard() + } + } + fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId @@ -233,34 +246,45 @@ fun MessagesView( Scaffold( contentWindowInsets = scaffoldScrollableContentInsets, topBar = { - if (state.timelineState.timelineMode is Timeline.Mode.Thread) { - ThreadTopBar( - roomName = state.roomName, - roomAvatarData = state.roomAvatar, - heroes = state.heroes, - isTombstoned = state.isTombstoned, - onBackClick = onBackClick, - ) - } else { - MessagesViewTopBar( - roomName = state.roomName, - roomAvatar = state.roomAvatar, - isTombstoned = state.isTombstoned, - heroes = state.heroes, - dmUserIdentityState = state.dmUserVerificationState, - sharedHistoryIcon = state.topBarSharedHistoryIcon, - dmUserStatus = state.dmUserStatus, - onBackClick = { hidingKeyboard { onBackClick() } }, - onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - menuActions = { - MessagesMenuActions( - displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, - roomCallState = state.roomCallState, - onJoinCallClick = onJoinCallClick, - onThreadsListClick = onThreadsListClick - ) - } - ) + when { + state.timelineState.isSelectionModeActive -> { + SelectionModeTopBar( + selectedCount = state.timelineState.selectedCount, + onCancelClick = { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + }, + ) + } + state.timelineState.timelineMode is Timeline.Mode.Thread -> { + ThreadTopBar( + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + heroes = state.heroes, + isTombstoned = state.isTombstoned, + onBackClick = onBackClick, + ) + } + else -> { + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + dmUserIdentityState = state.dmUserVerificationState, + sharedHistoryIcon = state.topBarSharedHistoryIcon, + dmUserStatus = state.dmUserStatus, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + menuActions = { + MessagesMenuActions( + displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, + roomCallState = state.roomCallState, + onJoinCallClick = onJoinCallClick, + onThreadsListClick = onThreadsListClick + ) + } + ) + } } }, content = { padding -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 8caa95f31d..66ed7dd757 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment @@ -109,6 +110,7 @@ class DefaultActionListPresenter( }.collectAsState(initial = persistentListOf()) val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) + val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false) fun handleEvent(event: ActionListEvent) { when (event) { @@ -120,6 +122,7 @@ class DefaultActionListPresenter( pinnedEventIds = pinnedEventIds, target = target, isThreadsEnabled = isThreadsEnabled.value, + isMultiSelectEnabled = isMultiSelectEnabled.value, ) } } @@ -137,6 +140,7 @@ class DefaultActionListPresenter( pinnedEventIds: ImmutableList, target: MutableState, isThreadsEnabled: Boolean, + isMultiSelectEnabled: Boolean, ) = launch { target.value = ActionListState.Target.Loading(timelineItem) @@ -146,6 +150,7 @@ class DefaultActionListPresenter( isDeveloperModeEnabled = isDeveloperModeEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isThreadsEnabled = isThreadsEnabled, + isMultiSelectEnabled = isMultiSelectEnabled, ) val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) @@ -179,6 +184,7 @@ class DefaultActionListPresenter( isDeveloperModeEnabled: Boolean, isEventPinned: Boolean, isThreadsEnabled: Boolean, + isMultiSelectEnabled: Boolean, ): List { val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther return buildSet { @@ -200,6 +206,9 @@ class DefaultActionListPresenter( if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { add(TimelineItemAction.Forward) } + if (isMultiSelectEnabled && timelineItem.canBeSelected()) { + add(TimelineItemAction.Select) + } if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.content is TimelineItemEventContentWithAttachment || timelineItem.content is TimelineItemGalleryContent || diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 3c64223e59..c7ee198ebc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -20,6 +20,7 @@ enum class TimelineItemAction( ) { ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), + Select(CommonStrings.action_select, CompoundDrawables.ic_compound_check), CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy), CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 2f6fa4e4e0..86b62794ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -19,6 +19,7 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.Reply, TimelineItemAction.ReplyInThread, TimelineItemAction.Forward, + TimelineItemAction.Select, TimelineItemAction.Edit, TimelineItemAction.EditPoll, TimelineItemAction.AddCaption, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 5daec3e275..f751250c90 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView @@ -291,6 +292,7 @@ private fun PinnedMessagesListLoaded( onContentLayoutChange = onContentLayoutChange ) }, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt new file mode 100644 index 0000000000..91b0d2e73a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.ImmutableSet + +/** Maximum number of messages that can be selected at once. */ +const val MAX_SELECTION_COUNT = 30 + +@Immutable +sealed interface SelectionState { + /** Selection mode is not active. */ + data object Disabled : SelectionState + + /** Selection mode is active with the given selection. */ + data class Active(val selectedEventIds: ImmutableSet) : SelectionState +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 66807b74f2..f21bfe88aa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,6 +29,10 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent + data class EnterSelectionMode(val eventId: EventId) : TimelineEvent + data class ToggleSelection(val eventId: EventId) : TimelineEvent + data object ExitSelectionMode : TimelineEvent + /** * Events coming from a timeline item. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 2fc4351cea..80812264fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -25,6 +25,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState @@ -33,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent @@ -46,6 +48,8 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -68,7 +72,9 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analyticsproviders.api.AnalyticsUserData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -100,6 +106,7 @@ class TimelinePresenter( private val typingNotificationPresenter: Presenter, private val roomCallStatePresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val markAsFullyRead: MarkAsFullyRead, @@ -164,6 +171,18 @@ class TimelinePresenter( value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread) } + val isMultiSelectEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it } + } + var selectionState by remember { mutableStateOf(SelectionState.Disabled) } + + // When the feature flag is turned off, clear any active selection. + LaunchedEffect(isMultiSelectEnabled) { + if (!isMultiSelectEnabled) { + selectionState = SelectionState.Disabled + } + } + val timelineProtectionState = timelineProtectionPresenter.present() fun handleEvent(event: TimelineEvent) { @@ -244,6 +263,28 @@ class TimelinePresenter( is TimelineEvent.JumpToLive -> { timelineController.focusOnLive() } + is TimelineEvent.EnterSelectionMode -> { + if (!isMultiSelectEnabled) return + selectionState = SelectionState.Active(persistentSetOf(event.eventId)) + } + is TimelineEvent.ToggleSelection -> { + val current = selectionState as? SelectionState.Active ?: return + val selected = current.selectedEventIds + selectionState = when { + event.eventId in selected -> { + val next = (selected - event.eventId).toPersistentSet() + if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next) + } + selected.size >= MAX_SELECTION_COUNT -> { + snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) + current + } + else -> SelectionState.Active((selected + event.eventId).toPersistentSet()) + } + } + is TimelineEvent.ExitSelectionMode -> { + selectionState = SelectionState.Disabled + } TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { @@ -353,6 +394,23 @@ class TimelinePresenter( computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } + // Reconcile the selection when the items change: drop any selected event that is no longer + // present or no longer selectable, and exit selection mode if nothing remains selected. + LaunchedEffect(timelineItems.map { it.identifier() }) { + val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect + val stillSelectable = timelineItems + .filterIsInstance() + .filter { it.canBeSelected() } + .mapNotNull { it.eventId } + .toSet() + val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet() + selectionState = when { + reconciled.isEmpty() -> SelectionState.Disabled + reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled) + else -> active + } + } + // Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the // read marker advances in place — the SDK swaps the marker virtual item to a new position // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the @@ -463,6 +521,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, + selectionState = selectionState, eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 1ebc96f15f..6bd5aa0895 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -37,6 +37,7 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, + val selectionState: SelectionState, val eventSink: (TimelineEvent) -> Unit, ) { private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event @@ -100,3 +101,14 @@ sealed interface JumpToUnreadState { /** The read marker event is older than the loaded window — load it via focused-event navigation. */ data class OutOfWindow(val eventId: EventId) : JumpToUnreadState } + +val TimelineState.isSelectionModeActive: Boolean + get() = selectionState is SelectionState.Active + +val TimelineState.selectedCount: Int + get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 + +fun TimelineState.isSelected(eventId: EventId?): Boolean { + if (eventId == null) return false + return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 89545b5d0b..5faea3de12 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -63,6 +63,7 @@ fun aTimelineState( displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, + selectionState: SelectionState = SelectionState.Disabled, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId @@ -84,6 +85,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, + selectionState = selectionState, eventSink = eventSink, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 5e7fd34723..0df9be3ca0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -83,14 +84,17 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.androidutils.system.copyToClipboard @@ -111,6 +115,7 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -189,6 +194,18 @@ fun TimelineView( state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) } + val isSelectionModeActive = state.isSelectionModeActive + // Animate once here (shared by every row) rather than per-row: the value is identical for all + // rows since it only depends on the timeline-wide selection mode. + val selectionProgress by animateFloatAsState( + targetValue = if (isSelectionModeActive) 1f else 0f, + label = "selectionProgress", + ) + fun onToggleSelection(event: TimelineItem.Event) { + val eventId = event.eventId ?: return + state.eventSink(TimelineEvent.ToggleSelection(eventId)) + } + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { Box(modifier) { @@ -214,12 +231,21 @@ fun TimelineView( isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, displayThreadSummaries = state.displayThreadSummaries, + selectionData = TimelineItemSelectionData( + isSelectionModeActive = isSelectionModeActive, + progress = selectionProgress, + isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId), + ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, - onContentClick = onContentClick, + onContentClick = { event -> + if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event) + }, onGalleryItemClick = onGalleryItemClick, - onLongClick = onMessageLongClick, + onLongClick = { event -> + if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event) + }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -616,6 +642,36 @@ internal fun TimelineViewPreview( } } +@PreviewsDayNight +@Composable +internal fun TimelineViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventId = timelineItems.filterIsInstance().first { it.canBeSelected() }.eventId!! + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(persistentSetOf(selectedEventId)), + ), + timelineProtectionState = aTimelineProtectionState(), + onUserDataClick = {}, + onLinkClick = {}, + onContentClick = {}, + onMessageLongClick = {}, + onSwipeToReply = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onJoinCallClick = {}, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, + forceJumpToBottomVisibility = true, + ) + } +} + // The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in // production the FAB only appears when unread content exists above. So `hasUnreadAbove` // here only varies the scroll-target state, not the upper indicator's visibility. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt new file mode 100644 index 0000000000..e980b47155 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline.components + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +/** + * Compact selection indicator used in the timeline selection mode, in place of a Material + * [androidx.compose.material3.Checkbox] (which reserves a 48dp interactive area). It is purely + * visual: the tap is handled by the whole-row click target, so this only reflects [selected]. + */ +@Composable +internal fun SelectionIndicator( + selected: Boolean, + modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier, + imageVector = if (selected) CompoundIcons.CheckCircleSolid() else CompoundIcons.Circle(), + contentDescription = null, + tint = if (selected) ElementTheme.colors.iconAccentPrimary else ElementTheme.colors.iconTertiary, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectionIndicatorPreview() = ElementPreview { + Icon( + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Circle(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectionIndicatorSelectedPreview() = ElementPreview { + SelectionIndicator(selected = true) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 99e72f4973..d8a8473891 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -59,6 +60,7 @@ import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection @@ -180,6 +182,9 @@ fun TimelineItemEventRow( onSwipeToReply: () -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, + // Horizontal offset applied to this row's content by selection mode. The read receipts are + // anchored to the end edge and should stay pinned, so they are counter-offset by this amount. + selectionContentOffset: Dp = 0.dp, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> // Only pass down a custom clickable lambda if the content can be clicked separately val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } @@ -326,7 +331,10 @@ fun TimelineItemEventRow( receipts = event.readReceiptState.receipts, ), onReadReceiptsClick = { onReadReceiptClick(event) }, - modifier = Modifier.padding(top = 4.dp) + // Counter the selection-mode content slide so the end-anchored read receipts stay pinned. + modifier = Modifier + .padding(top = 4.dp) + .offset(x = -selectionContentOffset) ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 5b620a7dc3..7094e302f8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -190,6 +190,7 @@ private fun TimelineItemGroupedEventsRowContent( onSwipeToReply = {}, eventSink = eventSink, eventContentView = eventContentView, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index fb9659ff70..7f08aa465e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -8,22 +8,30 @@ package io.element.android.features.messages.impl.timeline.components +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent @@ -31,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent @@ -60,6 +69,7 @@ internal fun TimelineItemRow( timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, displayThreadSummaries: Boolean, + selectionData: TimelineItemSelectionData, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -92,17 +102,130 @@ internal fun TimelineItemRow( ) }, ) { - val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { - val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { - 14.dp - } else { - 2.dp + val isSelectionModeActive = selectionData.isSelectionModeActive + val selectionProgress = selectionData.progress + val isSelected = selectionData.isSelected + val backgroundModifier = when { + isSelected -> { + Modifier.background(ElementTheme.colors.bgAccentSelected) + } + timelineItem.isEvent(focusedEventId) -> { + val focusedEventOffset = + if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { + 14.dp + } else { + 2.dp + } + Modifier.focusedEvent(focusedEventOffset) + } + else -> { + Modifier } - Modifier.focusedEvent(focusedEventOffset) - } else { - Modifier } - Box(modifier = modifier.then(backgroundModifier)) { + val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { it.canBeSelected() } + // Only start-aligned message bubbles slide to make room for the selection indicator: + // - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room. + // - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put. + val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true + val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing + val layoutDirection = LocalLayoutDirection.current + val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp + Box( + modifier = modifier.then(backgroundModifier) + ) { + TimelineItemRowContent( + modifier = Modifier + .fillMaxWidth() + .offset(x = contentSlide), + selectionContentOffset = contentSlide, + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, + onSwipeToReply = onSwipeToReply, + eventSink = eventSink, + eventContentView = eventContentView, + ) + if (selectableEvent != null && selectionProgress > 0f) { + val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f + SelectionIndicator( + selected = isSelected, + modifier = Modifier + .align(Alignment.CenterStart) + .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } + .padding(start = 12.dp) + .size(24.dp), + ) + } + if (isSelectionModeActive && selectableEvent != null) { + // In selection mode the whole row toggles selection. + Box( + modifier = Modifier + .matchParentSize() + .clickable { onContentClick(selectableEvent) } + ) + } + } +} + +/** Width reserved for the selection indicator; the row content slides by this amount. */ +private val SELECTION_COLUMN_WIDTH = 36.dp + +/** + * Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered + * tile (state event, legacy call invite, call/RTC notification, day divider, grouped events). + * Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection + * indicator; centered tiles stay put. + */ +private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event && + content !is TimelineItemStateContent && + content !is TimelineItemLegacyCallInviteContent && + content !is TimelineItemRtcNotificationContent + +@Suppress("LongMethod") +@Composable +private fun TimelineItemRowContent( + timelineItem: TimelineItem, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + isLastOutgoingMessage: Boolean, + timelineProtectionState: TimelineProtectionState, + focusedEventId: EventId?, + displayThreadSummaries: Boolean, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: (TimelineItem.Event, Int) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, + modifier: Modifier = Modifier, + selectionContentOffset: Dp = 0.dp, +) { + Box(modifier) { when (timelineItem) { is TimelineItem.Virtual -> { TimelineItemVirtualRow( @@ -197,6 +320,7 @@ internal fun TimelineItemRow( onSwipeToReply = { onSwipeToReply(timelineItem) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, + selectionContentOffset = selectionContentOffset, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt new file mode 100644 index 0000000000..f8de354aac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline.components + +/** + * Row-scoped selection state passed to [TimelineItemRow]. + * + * @param isSelectionModeActive whether the timeline is currently in selection mode. Drives + * behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress]. + * @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in). + * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] + * during the enter/exit animation. + * @param isSelected whether this specific row's event is part of the current selection. + */ +data class TimelineItemSelectionData( + val isSelectionModeActive: Boolean = false, + val isSelected: Boolean = false, + val progress: Float = 0f, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt new file mode 100644 index 0000000000..9f11e86213 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline.model + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent + +/** + * Whether this timeline event can be selected in multi-select ("selection") mode. + */ +fun TimelineItem.Event.canBeSelected(): Boolean { + // Local echoes (no remote eventId) and failed sends are never selectable. + if (!isRemote || failedToSend) return false + return when (content) { + is TimelineItemStateContent, + is TimelineItemRedactedContent, + is TimelineItemEncryptedContent, + is TimelineItemLegacyCallInviteContent, + is TimelineItemRtcNotificationContent, + is TimelineItemLocationContent, + is TimelineItemUnknownContent -> false + else -> true + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt new file mode 100644 index 0000000000..55563d1f1b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.topbars + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectionModeTopBar( + selectedCount: Int, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + title = { + Text(text = pluralStringResource(R.plurals.screen_room_timeline_selection_title, selectedCount, selectedCount)) + }, + navigationIcon = { + IconButton( + onClick = onCancelClick, + ) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectionModeTopBarPreview( + @PreviewParameter(SelectionCountProvider::class) count: Int, +) = ElementPreview { + SelectionModeTopBar(selectedCount = count, onCancelClick = {}) +} + +internal class SelectionCountProvider : PreviewParameterProvider { + override val values = sequenceOf(0, 1, 5) +} diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..5dfeaa7b85 --- /dev/null +++ b/features/messages/impl/src/main/res/values/temporary.xml @@ -0,0 +1,15 @@ + + + + + + %1$d selected + %1$d selected + + You can select up to 30 messages + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index e987a32480..2988fbe894 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -70,6 +70,44 @@ class ActionListPresenterTest { } } + @Test + fun `present - Select action is present for a remote message when multi-select is enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = aMessageEvent(), + userEventPermissions = aUserEventPermissions(canSendMessage = true), + ) + ) + val successState = awaitItem() + assertThat((successState.target as ActionListState.Target.Success).actions).contains(TimelineItemAction.Select) + } + } + + @Test + fun `present - Select action is absent when multi-select is disabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = aMessageEvent(), + userEventPermissions = aUserEventPermissions(canSendMessage = true), + ) + ) + val successState = awaitItem() + assertThat((successState.target as ActionListState.Target.Success).actions).doesNotContain(TimelineItemAction.Select) + } + } + @Test fun `present - compute for message from me redacted`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 290246ffb9..6dcf107e0b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -34,6 +34,7 @@ import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId @@ -86,6 +87,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -1821,6 +1823,80 @@ class TimelinePresenterTest { } } + @Test + fun `present - entering selection mode selects the target event`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID))) + assertThat(state.selectedCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - entering selection mode is ignored when the flag is off`() = runTest { + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)) + val presenter = createTimelinePresenter(featureFlagService = featureFlagService) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + expectNoEvents() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggling the last selected event exits selection mode`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - turning the flag off clears the active selection`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = featureFlagService, + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } @@ -1911,6 +1987,7 @@ class TimelinePresenterTest { typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, + snackbarDispatcher = SnackbarDispatcher(), analyticsService = FakeAnalyticsService(), liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt new file mode 100644 index 0000000000..e293246900 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline.model + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import org.junit.Test + +class TimelineItemSelectionTest { + @Test + fun `a remote text message can be selected`() { + val item = aTimelineItemEvent() + assertThat(item.canBeSelected()).isTrue() + } + + @Test + fun `a local echo cannot be selected`() { + val item = aTimelineItemEvent().copy(eventId = null) + assertThat(item.canBeSelected()).isFalse() + } + + @Test + fun `a failed to send message cannot be selected`() { + val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")) + assertThat(item.canBeSelected()).isFalse() + } + + @Test + fun `a state event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) + assertThat(item.canBeSelected()).isFalse() + } + + @Test + fun `a redacted event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) + assertThat(item.canBeSelected()).isFalse() + } +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index af7ccef97f..a50ebce5d7 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -161,4 +161,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + MessageMultiSelect( + key = "feature.message_multi_select", + title = "Multi-select messages", + description = "Adds a Select action to the message menu to pick several messages at once.", + defaultValue = { false }, + isFinished = false, + ), } From 93831ffcbce22831fbd191f62b5014db615f7e66 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:47:17 +0200 Subject: [PATCH 02/47] Forward several events at once --- .../features/forward/api/ForwardEntryPoint.kt | 2 +- .../forward/impl/DefaultForwardEntryPoint.kt | 2 +- .../forward/impl/ForwardMessagesNode.kt | 4 +-- .../forward/impl/ForwardMessagesPresenter.kt | 16 ++++----- .../impl/DefaultForwardEntryPointTest.kt | 4 +-- .../impl/ForwardMessagesPresenterTest.kt | 16 ++++----- .../libraries/matrix/api/timeline/Timeline.kt | 9 ++--- .../matrix/impl/room/RoomContentForwarder.kt | 36 ++++++++++--------- .../impl/timeline/RoomTimelineExtensions.kt | 13 ------- .../matrix/impl/timeline/RustTimeline.kt | 4 +-- .../matrix/test/timeline/FakeTimeline.kt | 6 ++-- 11 files changed, 50 insertions(+), 62 deletions(-) diff --git a/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt index 95b8c43808..bbc3915c86 100644 --- a/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt +++ b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt @@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint { } data class Params( - val eventId: EventId, + val eventIds: List, val timelineProvider: TimelineProvider, ) : NodeInputs diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt index 97256c6576..e46e268a55 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt @@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint { buildContext = buildContext, plugins = listOf( ForwardMessagesNode.Inputs( - eventId = params.eventId, + eventIds = params.eventIds, timelineProvider = params.timelineProvider, ), callback, diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt index 1c565a390a..30a48c91de 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -52,13 +52,13 @@ class ForwardMessagesNode( object NavTarget : Parcelable data class Inputs( - val eventId: EventId, + val eventIds: List, val timelineProvider: TimelineProvider, ) : NodeInputs private val inputs = inputs() private val callback: ForwardEntryPoint.Callback = callback() - private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider) + private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider) override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt index 6068866653..a36be20ab4 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt @@ -28,22 +28,20 @@ import timber.log.Timber @AssistedInject class ForwardMessagesPresenter( - @Assisted eventId: String, + @Assisted private val eventIds: List, @Assisted private val timelineProvider: TimelineProvider, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { - private val eventId: EventId = EventId(eventId) - @AssistedFactory fun interface Factory { - fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter + fun create(eventIds: List, timelineProvider: TimelineProvider): ForwardMessagesPresenter } private val forwardingActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.forwardEvent(eventId, roomIds) + sessionCoroutineScope.forwardEvents(eventIds, roomIds) } @Composable @@ -60,14 +58,14 @@ class ForwardMessagesPresenter( ) } - private fun CoroutineScope.forwardEvent( - eventId: EventId, + private fun CoroutineScope.forwardEvents( + eventIds: List, roomIds: List, ) = launch { suspend { - timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) + timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds) .onFailure { - Timber.e(it, "Error while forwarding event") + Timber.e(it, "Error while forwarding events") } .getOrThrow() roomIds diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt index 9ee932a246..c082e4ecd3 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt @@ -45,7 +45,7 @@ class DefaultForwardEntryPointTest { override fun onDone(roomIds: List) = lambdaError() } val params = ForwardEntryPoint.Params( - eventId = AN_EVENT_ID, + eventIds = listOf(AN_EVENT_ID), timelineProvider = FakeTimelineProvider(), ) val result = entryPoint.createNode( @@ -57,7 +57,7 @@ class DefaultForwardEntryPointTest { assertThat(result).isInstanceOf(ForwardMessagesNode::class.java) assertThat(result.plugins).contains( ForwardMessagesNode.Inputs( - eventId = params.eventId, + eventIds = params.eventIds, timelineProvider = params.timelineProvider, ) ) diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt index 0ff3210f2e..5fda2e252c 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt @@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest { @Test fun `present - forward successful`() = runTest { - val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + val forwardEventsLambda = lambdaRecorder { _: List, _: List -> Result.success(Unit) } val timeline = FakeTimeline().apply { - this.forwardEventLambda = forwardEventLambda + this.forwardEventsLambda = forwardEventsLambda } val room = FakeJoinedRoom(liveTimeline = timeline) val presenter = createForwardMessagesPresenter(fakeRoom = room) @@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest { assertThat(forwardingState.forwardAction.isLoading()).isTrue() val successfulForwardState = awaitItem() assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) - forwardEventLambda.assertions().isCalledOnce() + forwardEventsLambda.assertions().isCalledOnce() } } @Test fun `present - select a room and forward failed, then clear`() = runTest { - val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + val forwardEventsLambda = lambdaRecorder { _: List, _: List -> Result.failure(IllegalStateException("error")) } val timeline = FakeTimeline().apply { - this.forwardEventLambda = forwardEventLambda + this.forwardEventsLambda = forwardEventsLambda } val room = FakeJoinedRoom(liveTimeline = timeline) val presenter = createForwardMessagesPresenter(fakeRoom = room) @@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest { // Then clear error failedForwardState.eventSink(ForwardMessagesEvent.ClearError) assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() - forwardEventLambda.assertions().isCalledOnce() + forwardEventsLambda.assertions().isCalledOnce() } } } fun TestScope.createForwardMessagesPresenter( - eventId: EventId = AN_EVENT_ID, + eventIds: List = listOf(AN_EVENT_ID), fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), ) = ForwardMessagesPresenter( - eventId = eventId.value, + eventIds = eventIds, timelineProvider = LiveTimelineProvider(fakeRoom), sessionCoroutineScope = this, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 72483e68f1..51a8964aa0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -316,12 +316,13 @@ interface Timeline : AutoCloseable { suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result /** - * Forwards the content of an event to other rooms, as new messages sent by the current user. + * Forwards the content of some events to other rooms, as new messages sent by the current user. + * The events are forwarded in the order of [eventIds]. * - * @param eventId the event whose content is forwarded. - * @param roomIds the rooms to forward it to. + * @param eventIds the events whose content is forwarded. + * @param roomIds the rooms to forward them to. */ - suspend fun forwardEvent(eventId: EventId, roomIds: List): Result + suspend fun forwardEvents(eventIds: List, roomIds: List): Result /** * Cancels an event that is still waiting in the send queue, by redacting its local echo. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt index 39cde68c31..8191082806 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.ForwardEventException import io.element.android.libraries.matrix.impl.roomlist.roomOrNull -import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered import kotlinx.coroutines.withTimeout import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.RoomListService @@ -31,33 +30,36 @@ class RoomContentForwarder( private val roomListService: RoomListService, ) { /** - * Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds]. - * @param fromTimeline the room to forward the event from - * @param eventId the id of the event to forward - * @param toRoomIds the ids of the rooms to forward the event to - * @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room + * Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds]. + * The events are sent to each room in the order they are provided in [eventIds], it is up to the caller to sort them. + * @param fromTimeline the room to forward the events from + * @param eventIds the ids of the events to forward, in the order they must be sent + * @param toRoomIds the ids of the rooms to forward the events to + * @param timeoutMs the maximum time in milliseconds to wait for an event to be sent to a room */ suspend fun forward( fromTimeline: Timeline, - eventId: EventId, + eventIds: List, toRoomIds: List, timeoutMs: Long = 5000L ) { - val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content - ?: throw ForwardEventException(toRoomIds) - - val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content - ?: throw ForwardEventException(toRoomIds) - + val contents = eventIds.map { eventId -> + val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value) + val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds) + val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds) + contentWithoutRelationFromMessage(message.content) + } val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } val failedForwardingTo = mutableSetOf() targetRooms.parallelMap { room -> room.use { targetRoom -> runCatchingExceptions { - // Sending a message requires a registered timeline listener - targetRoom.timeline().runWithTimelineListenerRegistered { - withTimeout(timeoutMs.milliseconds) { - targetRoom.timeline().send(contentWithoutRelationFromMessage(content)) + targetRoom.timeline().use { timeline -> + // Send the contents sequentially, so that they are received in the same order + contents.forEach { content -> + withTimeout(timeoutMs.milliseconds) { + timeline.send(content) + } } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt index c932230217..9766da8235 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow> = }.catch { Timber.d(it, "timelineDiffFlow() failed") }.buffer(Channel.UNLIMITED) - -internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) { - val result = addListener(NoOpTimelineListener) - try { - action() - } finally { - result.cancelAndDestroy() - } -} - -private object NoOpTimelineListener : TimelineListener { - override fun onUpdate(diff: List) = Unit -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index c6a737e156..c7dd260d47 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -489,9 +489,9 @@ class RustTimeline( } } - override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = withContext(dispatcher) { + override suspend fun forwardEvents(eventIds: List, roomIds: List): Result = withContext(dispatcher) { runCatchingExceptions { - roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds) + roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds) }.onFailure { Timber.tag(loggerTag).e(it) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index d13ac06bd3..88dcb13a8c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -355,10 +355,10 @@ class FakeTimeline( ) } - var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> lambdaError() } + var forwardEventsLambda: (eventIds: List, roomIds: List) -> Result = { _, _ -> lambdaError() } - override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = simulateLongTask { - forwardEventLambda(eventId, roomIds) + override suspend fun forwardEvents(eventIds: List, roomIds: List): Result = simulateLongTask { + forwardEventsLambda(eventIds, roomIds) } var createPollLambda: ( From a0bfdd379c41dcf3868986777afb20faf30b077f Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:47:17 +0200 Subject: [PATCH 03/47] Pass a list of event ids to the forward screen --- .../appnav/room/joined/JoinedRoomLoadedFlowNode.kt | 2 +- .../features/messages/impl/MessagesFlowNode.kt | 14 +++++++------- .../features/messages/impl/MessagesNavigator.kt | 2 +- .../android/features/messages/impl/MessagesNode.kt | 6 +++--- .../messages/impl/threads/ThreadedMessagesNode.kt | 6 +++--- .../messages/impl/FakeMessagesNavigator.kt | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index dbe53b75ac..7205d095b4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode( } else { (graph as TimelineBindings).timelineProvider } - val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { backstack.pop() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 88a90f6c7d..4c86c75dd8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -179,7 +179,7 @@ class MessagesFlowNode( @Parcelize data class ForwardEvent( - val eventId: EventId, + val eventIds: List, val fromPinnedEvents: Boolean, ) : NavTarget @@ -303,8 +303,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun forwardEvent(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + override fun forwardEvents(eventIds: List) { + backstack.push(NavTarget.ForwardEvent(eventIds, fromPinnedEvents = false)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { @@ -464,7 +464,7 @@ class MessagesFlowNode( } else { timelineController } - val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { backstack.pop() @@ -551,7 +551,7 @@ class MessagesFlowNode( } override fun handleForwardEventClick(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true)) + backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), fromPinnedEvents = true)) } override fun navigateToThread(threadRootId: ThreadId) { @@ -617,8 +617,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun handleForwardEventClick(eventId: EventId) { - backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + override fun handleForwardEventClick(eventIds: List) { + backstack.push(NavTarget.ForwardEvent(eventIds, fromPinnedEvents = false)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 6113b68aab..b51c516214 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -18,7 +18,7 @@ import kotlinx.collections.immutable.ImmutableList interface MessagesNavigator { fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId) + fun forwardEvents(eventIds: List) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToEditPoll(eventId: EventId) fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 97d3465ce7..d95ba0bd45 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -130,7 +130,7 @@ class MessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId) + fun forwardEvents(eventIds: List) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -213,8 +213,8 @@ class MessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - callback.forwardEvent(eventId) + override fun forwardEvents(eventIds: List) { + callback.forwardEvents(eventIds) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index cf3c701015..01d466d83b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -135,7 +135,7 @@ class ThreadedMessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun handleForwardEventClick(eventId: EventId) + fun handleForwardEventClick(eventIds: List) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -219,8 +219,8 @@ class ThreadedMessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - callback.handleForwardEventClick(eventId) + override fun forwardEvents(eventIds: List) { + callback.handleForwardEventClick(eventIds) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 68f9a8d17a..1d9243af3d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList class FakeMessagesNavigator( private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, - private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, + private val onForwardEventClickLambda: (eventIds: List) -> Unit = { _ -> lambdaError() }, private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, @@ -34,8 +34,8 @@ class FakeMessagesNavigator( onShowEventDebugInfoClickLambda(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId) { - onForwardEventClickLambda(eventId) + override fun forwardEvents(eventIds: List) { + onForwardEventClickLambda(eventIds) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { From cec42ae4e6fff161475d491c57c372c3f621977b Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:47:28 +0200 Subject: [PATCH 04/47] Enter selection mode from the Forward action --- .../features/messages/impl/MessagesEvent.kt | 1 + .../messages/impl/MessagesPresenter.kt | 32 ++++--- .../features/messages/impl/MessagesView.kt | 8 +- .../impl/actionlist/ActionListPresenter.kt | 9 -- .../actionlist/model/TimelineItemAction.kt | 1 - .../model/TimelineItemActionComparator.kt | 1 - .../messages/impl/timeline/SelectionState.kt | 30 ++++++- .../messages/impl/timeline/TimelineEvent.kt | 2 +- .../impl/timeline/TimelinePresenter.kt | 26 +++--- .../messages/impl/timeline/TimelineState.kt | 37 +++++++- .../messages/impl/timeline/TimelineView.kt | 12 +-- .../components/TimelineItemSelectionData.kt | 1 + .../timeline/model/TimelineItemSelection.kt | 34 -------- .../impl/topbars/SelectionModeTopBar.kt | 21 ++++- .../messages/impl/MessagesPresenterTest.kt | 84 +++++++++++++++++-- .../actionlist/ActionListPresenterTest.kt | 38 --------- .../impl/timeline/SelectionActionTest.kt | 60 +++++++++++++ .../impl/timeline/TimelinePresenterTest.kt | 38 +++++++-- .../impl/timeline/TimelineStateTest.kt | 77 +++++++++++++++++ .../model/TimelineItemSelectionTest.kt | 47 ----------- 20 files changed, 379 insertions(+), 180 deletions(-) delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt delete mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 4d621e417f..adbf1f15ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -21,6 +21,7 @@ sealed interface MessagesEvent { data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent + data object ConfirmSelectionAction : MessagesEvent } enum class InviteDialogAction { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index bcbfc35255..5a24c64787 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -39,6 +39,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineState @@ -53,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent @@ -253,6 +256,17 @@ class MessagesPresenter( timelineProtectionState = timelineProtectionState, ) } + is MessagesEvent.ConfirmSelectionAction -> { + val selection = timelineState.selectionState as? SelectionState.Active + if (selection != null) { + when (selection.action) { + SelectionAction.Forward -> { + navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder()) + } + } + timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + } is MessagesEvent.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } @@ -395,17 +409,20 @@ class MessagesPresenter( } } TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) - TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.Forward -> { + val eventId = targetEvent.eventId ?: return@launch + if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) { + timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId)) + } else { + navigator.forwardEvents(listOf(eventId)) + } + } TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) - TimelineItemAction.Select -> { - val eventId = targetEvent.eventId ?: return@launch - timelineState.eventSink(TimelineEvent.EnterSelectionMode(eventId)) - } } } @@ -588,11 +605,6 @@ class MessagesPresenter( navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) } - private fun handleForwardAction(event: TimelineItem.Event) { - if (event.eventId == null) return - navigator.forwardEvent(event.eventId) - } - private fun handleReportAction(event: TimelineItem.Event) { if (event.eventId == null) return navigator.navigateToReportMessage(event.eventId, event.senderId) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index c4bcf552b5..0a82e52d08 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -10,8 +10,6 @@ package io.element.android.features.messages.impl import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -79,6 +77,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.aGroupedEvents @@ -248,11 +247,16 @@ fun MessagesView( topBar = { when { state.timelineState.isSelectionModeActive -> { + val selection = state.timelineState.selectionState as SelectionState.Active SelectionModeTopBar( selectedCount = state.timelineState.selectedCount, + action = selection.action, onCancelClick = { state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) }, + onConfirmClick = { + state.eventSink(MessagesEvent.ConfirmSelectionAction) + }, ) } state.timelineState.timelineMode is Timeline.Mode.Thread -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 66ed7dd757..8caa95f31d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -27,7 +27,6 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo -import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment @@ -110,7 +109,6 @@ class DefaultActionListPresenter( }.collectAsState(initial = persistentListOf()) val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) - val isMultiSelectEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collectAsState(false) fun handleEvent(event: ActionListEvent) { when (event) { @@ -122,7 +120,6 @@ class DefaultActionListPresenter( pinnedEventIds = pinnedEventIds, target = target, isThreadsEnabled = isThreadsEnabled.value, - isMultiSelectEnabled = isMultiSelectEnabled.value, ) } } @@ -140,7 +137,6 @@ class DefaultActionListPresenter( pinnedEventIds: ImmutableList, target: MutableState, isThreadsEnabled: Boolean, - isMultiSelectEnabled: Boolean, ) = launch { target.value = ActionListState.Target.Loading(timelineItem) @@ -150,7 +146,6 @@ class DefaultActionListPresenter( isDeveloperModeEnabled = isDeveloperModeEnabled, isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isThreadsEnabled = isThreadsEnabled, - isMultiSelectEnabled = isMultiSelectEnabled, ) val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) @@ -184,7 +179,6 @@ class DefaultActionListPresenter( isDeveloperModeEnabled: Boolean, isEventPinned: Boolean, isThreadsEnabled: Boolean, - isMultiSelectEnabled: Boolean, ): List { val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther return buildSet { @@ -206,9 +200,6 @@ class DefaultActionListPresenter( if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { add(TimelineItemAction.Forward) } - if (isMultiSelectEnabled && timelineItem.canBeSelected()) { - add(TimelineItemAction.Select) - } if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.content is TimelineItemEventContentWithAttachment || timelineItem.content is TimelineItemGalleryContent || diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index c7ee198ebc..3c64223e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -20,7 +20,6 @@ enum class TimelineItemAction( ) { ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), - Select(CommonStrings.action_select, CompoundDrawables.ic_compound_check), CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy), CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 86b62794ba..2f6fa4e4e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -19,7 +19,6 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.Reply, TimelineItemAction.ReplyInThread, TimelineItemAction.Forward, - TimelineItemAction.Select, TimelineItemAction.Edit, TimelineItemAction.EditPoll, TimelineItemAction.AddCaption, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt index 91b0d2e73a..f2738480aa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -7,18 +7,42 @@ package io.element.android.features.messages.impl.timeline +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable +import io.element.android.compound.R +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableSet /** Maximum number of messages that can be selected at once. */ -const val MAX_SELECTION_COUNT = 30 +const val MAX_SELECTION_COUNT = 10 @Immutable sealed interface SelectionState { /** Selection mode is not active. */ data object Disabled : SelectionState - /** Selection mode is active with the given selection. */ - data class Active(val selectedEventIds: ImmutableSet) : SelectionState + /** Selection mode is active for [action], with the given selection. */ + data class Active( + val action: SelectionAction, + val selectedEventIds: ImmutableSet, + ) : SelectionState +} + +/** + * An action which can be performed on several timeline events at once, and which selection mode can be entered for. + */ +enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRes val iconRes: Int) { + Forward(CommonStrings.action_forward, R.drawable.ic_compound_forward), +} + +/** + * Whether [event] can be part of a selection made for this action. + * Each action uses the same criteria as the single event action it mirrors. + */ +fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) { + SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index f21bfe88aa..ac67d93784 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,7 +29,7 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent - data class EnterSelectionMode(val eventId: EventId) : TimelineEvent + data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent data class ToggleSelection(val eventId: EventId) : TimelineEvent data object ExitSelectionMode : TimelineEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 80812264fd..320e362024 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -34,7 +34,6 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent @@ -265,7 +264,7 @@ class TimelinePresenter( } is TimelineEvent.EnterSelectionMode -> { if (!isMultiSelectEnabled) return - selectionState = SelectionState.Active(persistentSetOf(event.eventId)) + selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId)) } is TimelineEvent.ToggleSelection -> { val current = selectionState as? SelectionState.Active ?: return @@ -273,13 +272,13 @@ class TimelinePresenter( selectionState = when { event.eventId in selected -> { val next = (selected - event.eventId).toPersistentSet() - if (next.isEmpty()) SelectionState.Disabled else SelectionState.Active(next) + if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next) } selected.size >= MAX_SELECTION_COUNT -> { snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) current } - else -> SelectionState.Active((selected + event.eventId).toPersistentSet()) + else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet()) } } is TimelineEvent.ExitSelectionMode -> { @@ -394,19 +393,22 @@ class TimelinePresenter( computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } - // Reconcile the selection when the items change: drop any selected event that is no longer - // present or no longer selectable, and exit selection mode if nothing remains selected. - LaunchedEffect(timelineItems.map { it.identifier() }) { - val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect - val stillSelectable = timelineItems + val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active -> + timelineItems .filterIsInstance() - .filter { it.canBeSelected() } + .filter { active.action.canApplyTo(it) } .mapNotNull { it.eventId } .toSet() - val reconciled = active.selectedEventIds.filter { it in stillSelectable }.toPersistentSet() + }.orEmpty() + + // Reconcile the selection when the items change: drop any selected event that is no longer + // present or no longer selectable, and exit selection mode if nothing remains selected. + LaunchedEffect(selectableEventIds) { + val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect + val reconciled = active.selectedEventIds.filter { it in selectableEventIds }.toPersistentSet() selectionState = when { reconciled.isEmpty() -> SelectionState.Disabled - reconciled.size != active.selectedEventIds.size -> SelectionState.Active(reconciled) + reconciled.size != active.selectedEventIds.size -> active.copy(selectedEventIds = reconciled) else -> active } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 6bd5aa0895..bc571f6634 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -21,6 +21,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration data class TimelineState( @@ -108,7 +110,36 @@ val TimelineState.isSelectionModeActive: Boolean val TimelineState.selectedCount: Int get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 -fun TimelineState.isSelected(eventId: EventId?): Boolean { - if (eventId == null) return false - return (selectionState as? SelectionState.Active)?.selectedEventIds?.contains(eventId) == true +fun TimelineState.isSelected(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when(item){ + is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId) + else -> false + } +} + +/** + * Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event. + */ +fun TimelineState.canSelect(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when(item) { + is TimelineItem.Event -> selection.action.canApplyTo(item) + else -> false + } +} + +/** + * The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline. + * The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on. + */ +fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList { + val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf() + return timelineItems + .filterIsInstance() + .mapNotNull { it.eventId } + .filter { it in selectedEventIds } + // Most recent item is first in the timeline, so reverse the result. + .reversed() + .toImmutableList() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0df9be3ca0..eb9eef7c8f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -91,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent @@ -234,17 +233,18 @@ fun TimelineView( selectionData = TimelineItemSelectionData( isSelectionModeActive = isSelectionModeActive, progress = selectionProgress, - isSelected = state.isSelected((timelineItem as? TimelineItem.Event)?.eventId), + isSelected = state.isSelected(timelineItem), + canBeSelected = state.canSelect(timelineItem), ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, onContentClick = { event -> - if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onContentClick(event) + if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event) }, onGalleryItemClick = onGalleryItemClick, onLongClick = { event -> - if (isSelectionModeActive && event.canBeSelected()) onToggleSelection(event) else onMessageLongClick(event) + if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event) }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, @@ -646,14 +646,14 @@ internal fun TimelineViewPreview( @Composable internal fun TimelineViewSelectionModePreview() = ElementPreview { val timelineItems = aTimelineItemList(aTimelineItemTextContent()) - val selectedEventId = timelineItems.filterIsInstance().first { it.canBeSelected() }.eventId!! + val selectedEventId = timelineItems.filterIsInstance().first { SelectionAction.Forward.canApplyTo(it) }.eventId!! CompositionLocalProvider( LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), ) { TimelineView( state = aTimelineState( timelineItems = timelineItems, - selectionState = SelectionState.Active(persistentSetOf(selectedEventId)), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)), ), timelineProtectionState = aTimelineProtectionState(), onUserDataClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt index f8de354aac..641be44fe8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt @@ -20,5 +20,6 @@ package io.element.android.features.messages.impl.timeline.components data class TimelineItemSelectionData( val isSelectionModeActive: Boolean = false, val isSelected: Boolean = false, + val canBeSelected: Boolean = false, val progress: Float = 0f, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt deleted file mode 100644 index 9f11e86213..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelection.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 New Vector 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.messages.impl.timeline.model - -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent - -/** - * Whether this timeline event can be selected in multi-select ("selection") mode. - */ -fun TimelineItem.Event.canBeSelected(): Boolean { - // Local echoes (no remote eventId) and failed sends are never selectable. - if (!isRemote || failedToSend) return false - return when (content) { - is TimelineItemStateContent, - is TimelineItemRedactedContent, - is TimelineItemEncryptedContent, - is TimelineItemLegacyCallInviteContent, - is TimelineItemRtcNotificationContent, - is TimelineItemLocationContent, - is TimelineItemUnknownContent -> false - else -> true - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 55563d1f1b..1e96d7b1e1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.SelectionAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon @@ -28,7 +29,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SelectionModeTopBar( selectedCount: Int, + action: SelectionAction, onCancelClick: () -> Unit, + onConfirmClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -43,6 +46,17 @@ fun SelectionModeTopBar( Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) } }, + actions = { + IconButton( + onClick = onConfirmClick, + enabled = selectedCount > 0, + ) { + Icon( + resourceId = action.iconRes, + contentDescription = stringResource(action.titleRes), + ) + } + }, ) } @@ -51,7 +65,12 @@ fun SelectionModeTopBar( internal fun SelectionModeTopBarPreview( @PreviewParameter(SelectionCountProvider::class) count: Int, ) = ElementPreview { - SelectionModeTopBar(selectedCount = count, onCancelClick = {}) + SelectionModeTopBar( + selectedCount = count, + action = SelectionAction.Forward, + onCancelClick = {}, + onConfirmClick = {}, + ) } internal class SelectionCountProvider : PreviewParameterProvider { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 4c0abc0a34..152cb8a7a0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess import io.element.android.features.messages.impl.threads.list.aThreadListItem import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent +import io.element.android.features.messages.impl.timeline.TimelineState +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -76,6 +80,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId 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.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -113,6 +118,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testWithLifecycleOwner import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -285,17 +291,37 @@ class MessagesPresenterTest { } @Test - fun `present - handle action forward`() = runTest { - val onForwardEventClickLambda = lambdaRecorder { } - val navigator = FakeMessagesNavigator( - onForwardEventClickLambda = onForwardEventClickLambda, + fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest { + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + timelineEventSink = timelineEventSink, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), ) - val presenter = createMessagesPresenter(navigator = navigator) presenter.testWithLifecycleOwner { val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) - onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + timelineEventSink.assertions().isCalledOnce() + .with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle action forward navigates directly when multi select is disabled`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator( + onForwardEventClickLambda = onForwardEventClickLambda, + ) + val presenter = createMessagesPresenter( + navigator = navigator, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID))) } } @@ -1424,6 +1450,49 @@ class MessagesPresenterTest { canPinUnpin = canPinUnpin, ) + @Test + fun `present - confirm selection action forwards the selection in timeline order`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + navigator = navigator, + timelineEventSink = timelineEventSink, + timelineState = aTimelineState( + // The timeline is rendered from the most recent event to the oldest one. + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active( + SelectionAction.Forward, + persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID), + ), + eventSink = timelineEventSink, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - confirm selection action does nothing when no selection is active`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, Unit> { } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.createMessagesPresenter( coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), timeline: Timeline = FakeTimeline(), @@ -1446,6 +1515,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), timelineEventSink: (TimelineEvent) -> Unit = {}, + timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink), permalinkParser: PermalinkParser = FakePermalinkParser(), messageComposerPresenter: Presenter = Presenter { aMessageComposerState( @@ -1468,7 +1538,7 @@ class MessagesPresenterTest { room = joinedRoom, composerPresenter = messageComposerPresenter, voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), - timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, + timelinePresenter = { timelineState }, timelineProtectionPresenter = { aTimelineProtectionState() }, identityChangeStatePresenter = { anIdentityChangeState() }, linkPresenter = { aLinkState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 2988fbe894..e987a32480 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -70,44 +70,6 @@ class ActionListPresenterTest { } } - @Test - fun `present - Select action is present for a remote message when multi-select is enabled`() = runTest { - val presenter = createActionListPresenter( - isDeveloperModeEnabled = false, - featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke( - ActionListEvent.ComputeForMessage( - event = aMessageEvent(), - userEventPermissions = aUserEventPermissions(canSendMessage = true), - ) - ) - val successState = awaitItem() - assertThat((successState.target as ActionListState.Target.Success).actions).contains(TimelineItemAction.Select) - } - } - - @Test - fun `present - Select action is absent when multi-select is disabled`() = runTest { - val presenter = createActionListPresenter( - isDeveloperModeEnabled = false, - featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke( - ActionListEvent.ComputeForMessage( - event = aMessageEvent(), - userEventPermissions = aUserEventPermissions(canSendMessage = true), - ) - ) - val successState = awaitItem() - assertThat((successState.target as ActionListState.Target.Success).actions).doesNotContain(TimelineItemAction.Select) - } - } - @Test fun `present - compute for message from me redacted`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt new file mode 100644 index 0000000000..4939ec3a50 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import org.junit.Test + +class SelectionActionTest { + @Test + fun `Forward - a remote text message can be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent())).isTrue() + } + + @Test + fun `Forward - a local echo cannot be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent().copy(eventId = null))).isFalse() + } + + @Test + fun `Forward - a failed to send message cannot be selected`() { + // A message which failed to send is a local echo: it has no event id. + val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")).copy(eventId = null) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a state event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a redacted event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a sticker cannot be selected, it cannot be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemStickerContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a static location can be selected, it can be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemLocationContent(mode = aStaticLocationMode())) + assertThat(SelectionAction.Forward.canApplyTo(item)).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 6dcf107e0b..d6534deb23 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -50,8 +50,10 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -1835,9 +1837,9 @@ class TimelinePresenterTest { presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() - assertThat(state.selectionState).isEqualTo(SelectionState.Active(persistentSetOf(AN_EVENT_ID))) + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID))) assertThat(state.selectedCount).isEqualTo(1) cancelAndIgnoreRemainingEvents() } @@ -1849,7 +1851,7 @@ class TimelinePresenterTest { val presenter = createTimelinePresenter(featureFlagService = featureFlagService) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) expectNoEvents() assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) cancelAndIgnoreRemainingEvents() @@ -1867,7 +1869,7 @@ class TimelinePresenterTest { ) presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() @@ -1888,7 +1890,7 @@ class TimelinePresenterTest { ) presenter.test { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() - initialState.eventSink(TimelineEvent.EnterSelectionMode(AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() @@ -1897,6 +1899,32 @@ class TimelinePresenterTest { } } + @Test + fun `present - selection drops events the action cannot apply to`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + // The message is replaced by a state event, which cannot be forwarded. + timelineItems.value = listOf( + MatrixTimelineItem.Event( + UniqueId("1"), + anEventTimelineItem(eventId = AN_EVENT_ID, content = StateContent("", OtherState.RoomCreate)), + ) + ) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt new file mode 100644 index 0000000000..3ee818e438 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test + +class TimelineStateTest { + @Test + fun `selectedEventIdsInTimelineOrder - when selection is disabled, the list is empty`() { + val state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(eventId = AN_EVENT_ID)), + selectionState = SelectionState.Disabled, + ) + assertThat(state.selectedEventIdsInTimelineOrder()).isEmpty() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events are sorted from the oldest to the most recent one`() { + // The timeline items are rendered from the most recent to the oldest event. + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_3), + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + // The events have been tapped in a random order. + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID, AN_EVENT_ID_2, AN_EVENT_ID_3).inOrder() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events which are not in the timeline are ignored`() { + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `canSelect - returns false when selection mode is disabled`() { + val event = aTimelineItemEvent(eventId = AN_EVENT_ID) + val state = aTimelineState( + timelineItems = persistentListOf(event), + selectionState = SelectionState.Disabled, + ) + assertThat(state.canSelect(event)).isFalse() + } + + @Test + fun `canSelect - follows the action predicate when selection mode is active`() { + val forwardable = aTimelineItemEvent(eventId = AN_EVENT_ID) + val notForwardable = aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()) + val state = aTimelineState( + timelineItems = persistentListOf(forwardable, notForwardable), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)), + ) + assertThat(state.canSelect(forwardable)).isTrue() + assertThat(state.canSelect(notForwardable)).isFalse() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt deleted file mode 100644 index e293246900..0000000000 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2026 New Vector 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.messages.impl.timeline.model - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.timeline.aTimelineItemEvent -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent -import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState -import org.junit.Test - -class TimelineItemSelectionTest { - @Test - fun `a remote text message can be selected`() { - val item = aTimelineItemEvent() - assertThat(item.canBeSelected()).isTrue() - } - - @Test - fun `a local echo cannot be selected`() { - val item = aTimelineItemEvent().copy(eventId = null) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a failed to send message cannot be selected`() { - val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a state event cannot be selected`() { - val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) - assertThat(item.canBeSelected()).isFalse() - } - - @Test - fun `a redacted event cannot be selected`() { - val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) - assertThat(item.canBeSelected()).isFalse() - } -} From f4399a392ba962e69b4d4c6a21946b657928ada9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:47:48 +0200 Subject: [PATCH 05/47] Use a Checkbox for the timeline selection indicator --- .../timeline/components/SelectionIndicator.kt | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt index e980b47155..86261057c8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -7,44 +7,38 @@ package io.element.android.features.messages.impl.timeline.components -import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Checkbox /** - * Compact selection indicator used in the timeline selection mode, in place of a Material - * [androidx.compose.material3.Checkbox] (which reserves a 48dp interactive area). It is purely - * visual: the tap is handled by the whole-row click target, so this only reflects [selected]. + * Selection indicator used in the timeline selection mode. It renders the Compound [Checkbox], with the minimum + * interactive size enforcement disabled so that it fits the compact selection column. It is purely visual: the tap + * is handled by the whole-row click target, so this only reflects [selected]. */ @Composable internal fun SelectionIndicator( selected: Boolean, modifier: Modifier = Modifier, ) { - Icon( - modifier = modifier, - imageVector = if (selected) CompoundIcons.CheckCircleSolid() else CompoundIcons.Circle(), - contentDescription = null, - tint = if (selected) ElementTheme.colors.iconAccentPrimary else ElementTheme.colors.iconTertiary, - ) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Checkbox( + modifier = modifier, + checked = selected, + onCheckedChange = null, + ) + } } @PreviewsDayNight @Composable internal fun SelectionIndicatorPreview() = ElementPreview { - Icon( - modifier = Modifier.size(24.dp), - imageVector = CompoundIcons.Circle(), - contentDescription = null, - tint = ElementTheme.colors.iconTertiary, - ) + SelectionIndicator(selected = false) } @PreviewsDayNight From 0a11e88f06609ddc773bb3eaf5399ce2388a696a Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:48:58 +0200 Subject: [PATCH 06/47] Align the selection indicator with the message bubble --- .../timeline/components/MessageEventBubble.kt | 16 +++ .../timeline/components/TimelineItemRow.kt | 130 ++++++++++-------- 2 files changed, 88 insertions(+), 58 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 0dcf489fe3..7f4375e632 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.layer.CompositingStrategy +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.layout.layout import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.graphics.drawInLayer +import kotlin.math.min private val BUBBLE_RADIUS = 12.dp private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val MIN_BUBBLE_WIDTH = 80.dp +/** + * Alignment line published by [MessageEventBubble] at its vertical center. + * It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also + * covers the sender information, the reactions, the thread summary and the read receipts. + */ +val BubbleVerticalCenter = HorizontalAlignmentLine(::min) + @Composable fun MessageEventBubble( state: BubbleState, @@ -93,6 +103,12 @@ fun MessageEventBubble( val updatedBorderColor by rememberUpdatedState(borderColor) BoxWithConstraints( modifier = modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) { + placeable.place(0, 0) + } + } .drawWithCache { // Calculate the outline of the background and cache it val outline = bubbleShape.createOutline(size, layoutDirection, this) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 7f08aa465e..d547d823a8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl.timeline.components -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -18,18 +17,20 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -39,7 +40,6 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.canBeSelected import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent @@ -106,10 +106,7 @@ internal fun TimelineItemRow( val selectionProgress = selectionData.progress val isSelected = selectionData.isSelected val backgroundModifier = when { - isSelected -> { - Modifier.background(ElementTheme.colors.bgAccentSelected) - } - timelineItem.isEvent(focusedEventId) -> { + timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> { val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { 14.dp @@ -122,7 +119,7 @@ internal fun TimelineItemRow( Modifier } } - val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { it.canBeSelected() } + val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected } // Only start-aligned message bubbles slide to make room for the selection indicator: // - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room. // - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put. @@ -130,60 +127,77 @@ internal fun TimelineItemRow( val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing val layoutDirection = LocalLayoutDirection.current val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp - Box( - modifier = modifier.then(backgroundModifier) - ) { - TimelineItemRowContent( - modifier = Modifier - .fillMaxWidth() - .offset(x = contentSlide), - selectionContentOffset = contentSlide, - timelineItem = timelineItem, - timelineMode = timelineMode, - timelineRoomInfo = timelineRoomInfo, - timelineProtectionState = timelineProtectionState, - isLastOutgoingMessage = isLastOutgoingMessage, - focusedEventId = focusedEventId, - displayThreadSummaries = displayThreadSummaries, - onUserDataClick = onUserDataClick, - onLinkClick = onLinkClick, - onLinkLongClick = onLinkLongClick, - onContentClick = onContentClick, - onGalleryItemClick = onGalleryItemClick, - onLongClick = onLongClick, - inReplyToClick = inReplyToClick, - onReactionClick = onReactionClick, - onReactionLongClick = onReactionLongClick, - onMoreReactionsClick = onMoreReactionsClick, - onReadReceiptClick = onReadReceiptClick, - onJoinCallClick = onJoinCallClick, - onSwipeToReply = onSwipeToReply, - eventSink = eventSink, - eventContentView = eventContentView, - ) - if (selectableEvent != null && selectionProgress > 0f) { - val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f - SelectionIndicator( - selected = isSelected, - modifier = Modifier - .align(Alignment.CenterStart) - .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } - .padding(start = 12.dp) - .size(24.dp), - ) - } - if (isSelectionModeActive && selectableEvent != null) { - // In selection mode the whole row toggles selection. - Box( - modifier = Modifier - .matchParentSize() - .clickable { onContentClick(selectableEvent) } - ) + Layout( + modifier = modifier.then(backgroundModifier), + contents = listOf( + { + TimelineItemRowContent( + modifier = Modifier + .fillMaxWidth() + .offset(x = contentSlide), + selectionContentOffset = contentSlide, + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, + onSwipeToReply = onSwipeToReply, + eventSink = eventSink, + eventContentView = eventContentView, + ) + }, + { + if (selectableEvent != null && selectionProgress > 0f) { + val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f + SelectionIndicator( + selected = isSelected, + modifier = Modifier + .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } + .padding(start = 16.dp) + .size(24.dp), + ) + } + }, + { + if (isSelectionModeActive && selectableEvent != null) { + // In selection mode the whole row toggles selection. + Box(modifier = Modifier.clickable { onContentClick(selectableEvent) }) + } + }, + ), + ) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints -> + val contentPlaceable = contentMeasurables.first().measure(constraints) + val width = contentPlaceable.width + val height = contentPlaceable.height + val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0)) + val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height)) + layout(width = width, height = height) { + contentPlaceable.place(0, 0) + if (indicatorPlaceable != null) { + val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: (height / 2) + val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width + indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2) + } + overlayPlaceable?.place(0, 0) } } } -/** Width reserved for the selection indicator; the row content slides by this amount. */ +/** Width reserved for the selection indicator : the row content slides by this amount. */ private val SELECTION_COLUMN_WIDTH = 36.dp /** From 971f196d5429d6a4604a5529106c517db9b22b75 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:48:58 +0200 Subject: [PATCH 07/47] Intercept clicks on every row in selection mode --- .../messages/impl/timeline/components/TimelineItemRow.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index d547d823a8..3db8508617 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics @@ -173,9 +174,12 @@ internal fun TimelineItemRow( } }, { - if (isSelectionModeActive && selectableEvent != null) { + if (isSelectionModeActive) { // In selection mode the whole row toggles selection. - Box(modifier = Modifier.clickable { onContentClick(selectableEvent) }) + Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { + if(selectableEvent != null) + onContentClick(selectableEvent) + }) } }, ), From d8b66945754046bcac6f1b2b47b93c35a306f69c Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 25 Sep 2026 18:50:51 +0200 Subject: [PATCH 08/47] Apply ktlint formatting --- .../android/features/messages/impl/timeline/TimelineState.kt | 4 ++-- .../messages/impl/timeline/components/TimelineItemRow.kt | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index bc571f6634..f8976de874 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -112,7 +112,7 @@ val TimelineState.selectedCount: Int fun TimelineState.isSelected(item: TimelineItem): Boolean { val selection = selectionState as? SelectionState.Active ?: return false - return when(item){ + return when (item) { is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId) else -> false } @@ -123,7 +123,7 @@ fun TimelineState.isSelected(item: TimelineItem): Boolean { */ fun TimelineState.canSelect(item: TimelineItem): Boolean { val selection = selectionState as? SelectionState.Active ?: return false - return when(item) { + return when (item) { is TimelineItem.Event -> selection.action.canApplyTo(item) else -> false } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 3db8508617..6f26b8c441 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -177,8 +177,9 @@ internal fun TimelineItemRow( if (isSelectionModeActive) { // In selection mode the whole row toggles selection. Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { - if(selectableEvent != null) - onContentClick(selectableEvent) + if (selectableEvent != null) { + onContentClick(selectableEvent) + } }) } }, From 69a1870f58a952c6e78ddcbbdf0087348e30a361 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 29 Sep 2026 16:35:33 +0200 Subject: [PATCH 09/47] Use existing strings --- .../messages/impl/timeline/TimelinePresenter.kt | 3 ++- .../messages/impl/topbars/SelectionModeTopBar.kt | 3 ++- .../impl/src/main/res/values/temporary.xml | 15 --------------- 3 files changed, 4 insertions(+), 17 deletions(-) delete mode 100644 features/messages/impl/src/main/res/values/temporary.xml diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 320e362024..a712e47cba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom @@ -275,7 +276,7 @@ class TimelinePresenter( if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next) } selected.size >= MAX_SELECTION_COUNT -> { - snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_selection_limit_reached)) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected)) current } else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet()) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 1e96d7b1e1..e918c0da2b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings @OptIn(ExperimentalMaterial3Api::class) @@ -37,7 +38,7 @@ fun SelectionModeTopBar( TopAppBar( modifier = modifier, title = { - Text(text = pluralStringResource(R.plurals.screen_room_timeline_selection_title, selectedCount, selectedCount)) + Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount)) }, navigationIcon = { IconButton( diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml deleted file mode 100644 index 5dfeaa7b85..0000000000 --- a/features/messages/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - %1$d selected - %1$d selected - - You can select up to 30 messages - From 62f9b7f0b40cc112b3fdc68c26438ea4d6fc7ae8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 29 Sep 2026 20:53:51 +0200 Subject: [PATCH 10/47] Use "Forward to" as forward screen title --- .../android/libraries/roomselect/impl/RoomSelectView.kt | 2 +- libraries/ui-strings/src/main/res/values/localazy.xml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 43b91068f9..118acf25cb 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -123,7 +123,7 @@ fun RoomSelectView( topBar = { TopAppBar( titleStr = when (state.mode) { - RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_to) RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to) }, navigationIcon = { diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 7a0ce8fd4e..0079c8f47e 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -98,6 +98,7 @@ "Choose file" "Choose photo" "Clear" + "Clear all" "Close" "Complete verification" "Confirm" @@ -286,6 +287,7 @@ Reason: %1$s." "File saved" "File saved to Downloads" "Forward message" + "Forward to" "Frequently used" "Gallery" @@ -412,6 +414,7 @@ Reason: %1$s." "Shared live location" "Shared location" "Shared space" + "Signed in" "Removing device" "Something went wrong" "We encountered an issue. Please try again." From bb649838322fc56b06e1b00ca680a24f2fb95c3c Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 30 Sep 2026 11:48:31 +0200 Subject: [PATCH 11/47] Move TimelineItemSelectionData to model package --- .../messages/impl/pinned/list/PinnedMessagesListView.kt | 2 +- .../features/messages/impl/timeline/TimelinePresenter.kt | 1 - .../features/messages/impl/timeline/TimelineView.kt | 2 +- .../timeline/components/TimelineItemGroupedEventsRow.kt | 1 + .../messages/impl/timeline/components/TimelineItemRow.kt | 1 + .../{components => model}/TimelineItemSelectionData.kt | 8 +++++--- .../features/messages/impl/topbars/SelectionModeTopBar.kt | 1 - 7 files changed, 9 insertions(+), 7 deletions(-) rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/{components => model}/TimelineItemSelectionData.kt (66%) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index c47b93b02b..f7bba24f95 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -33,13 +33,13 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow -import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.poll.api.pollcontent.PollTitleView diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 3c0c22a2e9..c46e28de1a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -25,7 +25,6 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.messages.impl.MessagesNavigator -import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 71c7bd3540..f948f478b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -84,13 +84,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay import io.element.android.features.messages.impl.timeline.components.TimelineItemRow -import io.element.android.features.messages.impl.timeline.components.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.components.toText import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 1cd10fdf01..b03b76dd79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.preview.ElementPreview diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index e1b3204552..32cef08ea7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt similarity index 66% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt index 641be44fe8..60a30d5c73 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemSelectionData.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt @@ -1,14 +1,14 @@ /* - * Copyright 2026 New Vector Ltd. + * 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.messages.impl.timeline.components +package io.element.android.features.messages.impl.timeline.model /** - * Row-scoped selection state passed to [TimelineItemRow]. + * Row-scoped selection state passed to [io.element.android.features.messages.impl.timeline.components.TimelineItemRow]. * * @param isSelectionModeActive whether the timeline is currently in selection mode. Drives * behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress]. @@ -16,6 +16,8 @@ package io.element.android.features.messages.impl.timeline.components * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] * during the enter/exit animation. * @param isSelected whether this specific row's event is part of the current selection. + * @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for. + * Rows which cannot be selected render no indicator, and their content does not slide. */ data class TimelineItemSelectionData( val isSelectionModeActive: Boolean = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index e918c0da2b..1d12699277 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.SelectionAction import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight From ae409aa8d2da4fbeeeaec22fabe0d7b44e020df6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 30 Sep 2026 11:49:26 +0200 Subject: [PATCH 12/47] Add preview for messages view in selection mode --- .../features/messages/impl/MessagesView.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 9097ed682b..b4f084ebd6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -77,13 +77,16 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.SelectionAction import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.aGroupedEvents import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.canApplyTo import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent @@ -138,6 +141,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentSet import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -756,6 +760,40 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::cl ) } +@PreviewsDayNight +@Composable +internal fun MessagesViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventIds = timelineItems + .filterIsInstance() + .filter { SelectionAction.Forward.canApplyTo(it) } + .take(2) + .mapNotNull { it.eventId } + .toPersistentSet() + MessagesView( + state = aMessagesState( + timelineState = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(SelectionAction.Forward, selectedEventIds), + ), + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onGalleryEventItemClick = { _, _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = { }, + forceJumpToBottomVisibility = true, + knockRequestsBannerView = {}, + customReactionBottomSheet = {}, + onThreadsListClick = {}, + ) +} + @Preview @Composable internal fun MessagesViewA11yPreview() = ElementPreview { From e99861a5c12a3f778eef5df46231955337e0cb94 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 2 Oct 2026 20:41:04 +0200 Subject: [PATCH 13/47] Keep selection mode active until explicitly exited --- .../features/messages/impl/MessagesView.kt | 5 ++ .../messages/impl/timeline/SelectionState.kt | 42 ++++++++- .../impl/timeline/TimelinePresenter.kt | 36 +++----- .../impl/timeline/SelectionStateTest.kt | 89 +++++++++++++++++++ .../impl/timeline/TimelinePresenterTest.kt | 70 ++++++++++++--- 5 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b4f084ebd6..92c91dfee5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -194,6 +195,10 @@ fun MessagesView( } } + BackHandler(enabled = state.timelineState.isSelectionModeActive) { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt index f2738480aa..7a9f073bc0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet /** Maximum number of messages that can be selected at once. */ const val MAX_SELECTION_COUNT = 10 @@ -25,7 +26,10 @@ sealed interface SelectionState { /** Selection mode is not active. */ data object Disabled : SelectionState - /** Selection mode is active for [action], with the given selection. */ + /** + * Selection mode is active for [action], with the given selection. + * [selectedEventIds] can be empty: selection mode is never exited automatically, only when the user explicitly leaves it. + */ data class Active( val action: SelectionAction, val selectedEventIds: ImmutableSet, @@ -46,3 +50,39 @@ enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRe fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) { SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded() } + +/** + * This selection without [eventIds]. + * Selection mode stays active even when nothing remains selected, see [SelectionState.Active]. + */ +fun SelectionState.deselect(eventIds: Set): SelectionState = when (this) { + SelectionState.Disabled -> this + is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toPersistentSet()) +} + +/** + * The ids of the selected events which [SelectionState.Active.action] cannot be applied to anymore, for instance because the message has been + * deleted. They are meant to be given to [deselect]. + */ +fun SelectionState.eventIdsToDeselect(timelineItems: List): Set { + val selection = this as? SelectionState.Active ?: return emptySet() + if (selection.selectedEventIds.isEmpty()) return emptySet() + val result = mutableSetOf() + + fun process(event: TimelineItem.Event) { + val eventId = event.eventId ?: return + if (eventId in selection.selectedEventIds && !selection.action.canApplyTo(event)) { + result.add(eventId) + } + } + + timelineItems.forEach { item -> + when (item) { + is TimelineItem.Event -> process(item) + // A deleted message can be folded into a group of deleted messages, so look inside the groups too. + is TimelineItem.GroupedEvents -> item.events.forEach(::process) + is TimelineItem.Virtual -> Unit + } + } + return result +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index c46e28de1a..a7d4641085 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -77,9 +77,11 @@ import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -273,10 +275,8 @@ class TimelinePresenter( val current = selectionState as? SelectionState.Active ?: return val selected = current.selectedEventIds selectionState = when { - event.eventId in selected -> { - val next = (selected - event.eventId).toPersistentSet() - if (next.isEmpty()) SelectionState.Disabled else current.copy(selectedEventIds = next) - } + // Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS. + event.eventId in selected -> current.deselect(setOf(event.eventId)) selected.size >= MAX_SELECTION_COUNT -> { snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected)) current @@ -371,6 +371,14 @@ class TimelinePresenter( } .launchIn(this) + // Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted. + timelineItemsFactory.timelineItems + .map { items -> selectionState.eventIdsToDeselect(items) } + .filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() } + .flowOn(dispatchers.computation) + .onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) } + .launchIn(this) + combine( timelineController.timelineItems(), room.membersStateFlow, @@ -396,26 +404,6 @@ class TimelinePresenter( computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) } - val selectableEventIds = (selectionState as? SelectionState.Active)?.let { active -> - timelineItems - .filterIsInstance() - .filter { active.action.canApplyTo(it) } - .mapNotNull { it.eventId } - .toSet() - }.orEmpty() - - // Reconcile the selection when the items change: drop any selected event that is no longer - // present or no longer selectable, and exit selection mode if nothing remains selected. - LaunchedEffect(selectableEventIds) { - val active = selectionState as? SelectionState.Active ?: return@LaunchedEffect - val reconciled = active.selectedEventIds.filter { it in selectableEventIds }.toPersistentSet() - selectionState = when { - reconciled.isEmpty() -> SelectionState.Disabled - reconciled.size != active.selectedEventIds.size -> active.copy(selectedEventIds = reconciled) - else -> active - } - } - // Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the // read marker advances in place — the SDK swaps the marker virtual item to a new position // without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt new file mode 100644 index 0000000000..ea45813a10 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 New Vector 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.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentSet +import org.junit.Test + +class SelectionStateTest { + @Test + fun `eventIdsToDeselect - selection mode being disabled has nothing to drop`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent())) + assertThat(SelectionState.Disabled.eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - an empty selection has nothing to drop`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent())) + assertThat(anActiveSelection().eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a selected event the action still applies to is kept`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID)) + assertThat(anActiveSelection(listOf(AN_EVENT_ID)).eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a selected event which has been deleted is dropped`() { + val items = listOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ) + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `eventIdsToDeselect - a selected event which is not in the timeline is ignored`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID)) + // AN_EVENT_ID_3 may simply not be loaded anymore, so it is not dropped here. + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_3)).eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a deleted selected event folded into a group is dropped`() { + val items = listOf( + TimelineItem.GroupedEvents( + id = UniqueId("group"), + events = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()), + aTimelineItemEvent(eventId = AN_EVENT_ID_3, content = aTimelineItemRedactedContent()), + ), + aggregatedReadReceipts = persistentListOf(), + ), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ) + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `deselect - removes the given events and keeps selection mode active`() { + val selection = anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)) + assertThat(selection.deselect(setOf(AN_EVENT_ID))).isEqualTo(anActiveSelection(listOf(AN_EVENT_ID_2))) + assertThat(selection.deselect(setOf(AN_EVENT_ID, AN_EVENT_ID_2))).isEqualTo(anActiveSelection()) + } + + @Test + fun `deselect - does nothing when selection mode is disabled`() { + assertThat(SelectionState.Disabled.deselect(setOf(AN_EVENT_ID))).isEqualTo(SelectionState.Disabled) + } + + private fun anActiveSelection(eventIds: List = emptyList()) = SelectionState.Active( + action = SelectionAction.Forward, + selectedEventIds = eventIds.toPersistentSet(), + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 45667e298c..eeb97befff 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -50,10 +50,8 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState -import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt -import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -1862,7 +1860,7 @@ class TimelinePresenterTest { } @Test - fun `present - toggling the last selected event exits selection mode`() = runTest { + fun `present - toggling the last selected event keeps selection mode active`() = runTest { val timelineItems = MutableStateFlow( listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) ) @@ -1875,6 +1873,28 @@ class TimelinePresenterTest { initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) + // Selection mode is only exited explicitly, so an empty selection keeps it active. + val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf())) + assertThat(state.isSelectionModeActive).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - exiting selection mode clears the selection`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + initialState.eventSink(TimelineEvent.ExitSelectionMode) val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) cancelAndIgnoreRemainingEvents() @@ -1903,7 +1923,35 @@ class TimelinePresenterTest { } @Test - fun `present - selection drops events the action cannot apply to`() = runTest { + fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest { + val timelineItems = MutableStateFlow( + listOf( + MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())), + MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())), + ) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2)) + consumeItemsUntilPredicate { it.selectedCount == 2 } + // The first message is deleted, so it cannot be forwarded anymore. + timelineItems.value = listOf( + MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())), + MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())), + ) + val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest { val timelineItems = MutableStateFlow( listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) ) @@ -1915,15 +1963,11 @@ class TimelinePresenterTest { val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) consumeItemsUntilPredicate { it.isSelectionModeActive } - // The message is replaced by a state event, which cannot be forwarded. - timelineItems.value = listOf( - MatrixTimelineItem.Event( - UniqueId("1"), - anEventTimelineItem(eventId = AN_EVENT_ID, content = StateContent("", OtherState.RoomCreate)), - ) - ) - val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() - assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + // The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline. + timelineItems.value = emptyList() + val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last() + // The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it. + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID))) cancelAndIgnoreRemainingEvents() } } From 84fdbbe573923d9a6881bf91f4b5a244efb27873 Mon Sep 17 00:00:00 2001 From: mxandreas Date: Mon, 5 Oct 2026 09:30:01 +0300 Subject: [PATCH 14/47] Add a hook to disable public rooms/spaces on UI. --- enterprise | 2 +- .../configureroom/ConfigureRoomPresenter.kt | 9 +++- .../impl/ConfigureRoomPresenterTest.kt | 43 +++++++++++++++++- .../api/SessionEnterpriseService.kt | 6 +++ .../impl/DefaultSessionEnterpriseService.kt | 1 + .../test/FakeSessionEnterpriseService.kt | 5 +++ .../impl/root/SecurityAndPrivacyPresenter.kt | 5 +++ .../impl/root/SecurityAndPrivacyState.kt | 6 +++ .../SecurityAndPrivacyStatePreviewParam.kt | 6 +++ .../impl/root/SecurityAndPrivacyView.kt | 16 ++++--- .../root/SecurityAndPrivacyPresenterTest.kt | 44 ++++++++++++++++++- 11 files changed, 130 insertions(+), 13 deletions(-) diff --git a/enterprise b/enterprise index 073abe3b44..d25d658acb 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 073abe3b44598a072a87a9c92d15c6e5bc4a88af +Subproject commit d25d658acbee55e25d20a2f22f411040adfb078c diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 849647a9d3..86cd67546e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -92,6 +93,9 @@ class ConfigureRoomPresenter( val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(initial = false) + val arePublicRoomsDisabled by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } val roomAddressValidity = remember { mutableStateOf(RoomAddressValidity.Unknown) } @@ -145,12 +149,13 @@ class ConfigureRoomPresenter( // 1. If we are creating a space. // 2. If it has a parent space. // 3. If knocking is enabled. + // 4. If the homeserver forbids public rooms. val parentSpace = createRoomConfig.parentSpace - val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) { + val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) { when { isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( - JoinRuleItem.PublicVisibility.Public, + JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.Private, ).toImmutableList() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index 09afd38f90..ebd3cdcdc2 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -57,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { @Test fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val createRoomLambda = lambdaRecorder> { Result.success(A_ROOM_ID) } val matrixClient = createMatrixClient().apply { createRoomResult = createRoomLambda @@ -544,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() { } } + @Test + fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest { + val presenter = createConfigureRoomPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private) + val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last() + assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest { + val presenter = createConfigureRoomPresenter( + isSpace = true, + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last() + assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { skipItems(1) return awaitItem() @@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), + sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, + ), ) = ConfigureRoomPresenter( isSpace = isSpace, initialParentSpaceId = initialParenSpaceId, diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index 94f455f2ff..aff9a3ae5c 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -29,4 +29,10 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean + + /** + * Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one, + * nor to make an existing room or space public. + */ + suspend fun arePublicRoomsDisabledByHomeserver(): Boolean } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt index 3ac10d8a08..8a528257a6 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService { override suspend fun tweakMasUrl(url: String): String = url override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt index e198fca867..a461b87c95 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -16,6 +16,7 @@ class FakeSessionEnterpriseService( private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, + private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() }, ) : SessionEnterpriseService { override suspend fun init() { } @@ -31,4 +32,8 @@ class FakeSessionEnterpriseService( override suspend fun isEncryptionDisabledByHomeserver(): Boolean { return isEncryptionDisabledResult() } + + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean { + return arePublicRoomsDisabledResult() + } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index 32517cd18d..498e71dd0a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter( value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() } + val arePublicRoomsDisabledByHomeserver by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } + val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } LaunchedEffect(Unit) { isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory) @@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter( selectableJoinedSpaces = selectableJoinedSpaces, spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = ::handleEvent, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index ddba5d0caa..5b61953674 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -28,6 +28,7 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val isEncryptionDisabledByHomeserver: Boolean, + private val arePublicRoomsDisabledByHomeserver: Boolean, val showEnableEncryptionConfirmation: Boolean, private val isKnockEnabled: Boolean, val saveAction: AsyncAction, @@ -39,6 +40,11 @@ data class SecurityAndPrivacyState( ) { val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None + // Show Anyone option when: + // - Anyone is the current saved value, OR + // - The homeserver allows public rooms + val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver + // Show SpaceMember option in two cases: // - SpaceMember is the current saved value // - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt index 102adb4015..45383bf814 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt @@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider = emptySet(), spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, isEncryptionDisabledByHomeserver: Boolean = false, + arePublicRoomsDisabledByHomeserver: Boolean = false, eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState( selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt index eeaa9bbc13..5b224f682c 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -232,13 +232,15 @@ private fun RoomAccessSection( title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), modifier = modifier, ) { - ListItem( - content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, - supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, - trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), - onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, - ) + if (state.showAnyoneOption) { + ListItem( + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, + ) + } if (state.showSpaceMemberOption) { ListItem( content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 8cdba871be..5a0cf743e2 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest { @Test fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val presenter = createSecurityAndPrivacyPresenter( sessionEnterpriseService = sessionEnterpriseService, ) @@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest { } } + @Test + fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + consumeItemsUntilPredicate { !it.showAnyoneOption } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Public) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public) + ), + ), + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val states = consumeItemsUntilTimeout() + assertThat(states).isNotEmpty() + assertThat(states.all { it.showAnyoneOption }).isTrue() + } + } + private fun roomPermissions( canChangeRoomAccess: Boolean = true, canChangeHistoryVisibility: Boolean = true, @@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest { spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, ) ): SecurityAndPrivacyPresenter { return SecurityAndPrivacyPresenter( From 54409a82af0395b68af5afdb467939bc9d9fe97f Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 5 Oct 2026 10:02:52 +0000 Subject: [PATCH 15/47] Update screenshots --- ...tyandprivacy.impl.root_SecurityAndPrivacyViewDark_24_en.png | 3 +++ ...yandprivacy.impl.root_SecurityAndPrivacyViewLight_24_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_24_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_24_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_24_en.png b/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_24_en.png new file mode 100644 index 0000000000..94f775e378 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_24_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47690874ef06996ff6a6617443f79596c5ed124de15831e0a3ec791a99890613 +size 35568 diff --git a/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_24_en.png b/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_24_en.png new file mode 100644 index 0000000000..cba7c2b779 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_24_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3cd133c2ca779fcf03c9afa227a3187cb2de655bc313aa176505593f855f6ff +size 37039 From 721cedbc459947eb7a5c8a516bf54b6d1d65da6c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 5 Oct 2026 16:25:38 +0000 Subject: [PATCH 16/47] Update screenshots --- ...imeline.components_SelectionIndicatorSelected_Day_0_en.png | 3 +++ ...eline.components_SelectionIndicatorSelected_Night_0_en.png | 3 +++ ...s.impl.timeline.components_SelectionIndicator_Day_0_en.png | 3 +++ ...impl.timeline.components_SelectionIndicator_Night_0_en.png | 3 +++ ...sages.impl.timeline_TimelineViewSelectionMode_Day_0_en.png | 3 +++ ...ges.impl.timeline_TimelineViewSelectionMode_Night_0_en.png | 3 +++ ...res.messages.impl.topbars_SelectionModeTopBar_Day_0_en.png | 3 +++ ...res.messages.impl.topbars_SelectionModeTopBar_Day_1_en.png | 3 +++ ...res.messages.impl.topbars_SelectionModeTopBar_Day_2_en.png | 3 +++ ...s.messages.impl.topbars_SelectionModeTopBar_Night_0_en.png | 3 +++ ...s.messages.impl.topbars_SelectionModeTopBar_Night_1_en.png | 3 +++ ...s.messages.impl.topbars_SelectionModeTopBar_Night_2_en.png | 3 +++ ...tures.messages.impl_MessagesViewSelectionMode_Day_0_en.png | 3 +++ ...res.messages.impl_MessagesViewSelectionMode_Night_0_en.png | 3 +++ .../libraries.roomselect.impl_RoomSelectView_Day_0_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_1_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_0_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_1_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_4_en.png | 4 ++-- 24 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Day_0_en.png new file mode 100644 index 0000000000..daa500f1ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bc29e05e6f4bd8f8e9278c94b4f25e5154ed8c11f7c733aac16960e047599d0 +size 4309 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Night_0_en.png new file mode 100644 index 0000000000..6ff7e570d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicatorSelected_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6243cdd7d3199a355c1ff5f5332bfd29c38f3b0ccd45dc0af6efdbc81c564e5 +size 4194 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Day_0_en.png new file mode 100644 index 0000000000..89451164a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0665afc8ecb2119b0472adc7b5aaf5ff1726dc3538bab5ccdc37e4710569d1e +size 3894 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Night_0_en.png new file mode 100644 index 0000000000..1da717df5c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_SelectionIndicator_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a61a03abf31e3143f1e02e4dd978e35b63bf2df0a634333edd08f0ee1fc8f1e +size 3881 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Day_0_en.png new file mode 100644 index 0000000000..cac3d8f820 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd3ea7b8a04d58c1b3a825eeeeb798e3cf5ef333531d07456f654266706a8304 +size 44353 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Night_0_en.png new file mode 100644 index 0000000000..b54609e4c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineViewSelectionMode_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3aa79e39645f154961fac99f4a58f3f0393225ba898e155f5a87acdbaa1df146 +size 45998 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_0_en.png new file mode 100644 index 0000000000..7b034776af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86162a6692bab6684d7ca14c69085d6c3da52cc5b2efca76da307c3df626342a +size 8095 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_1_en.png new file mode 100644 index 0000000000..9f1e06b15c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca18b30acb9cca3d6c7baa204a847efdee0ad7dac55659cae6f5085b62bd878e +size 7619 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_2_en.png new file mode 100644 index 0000000000..2c244facd5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbb3853157d790596a1ee73dee89967efcd0a2dadaa004d745e70f79ebb9910b +size 7922 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_0_en.png new file mode 100644 index 0000000000..d96bcd8dcd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4c0ae289da14f583717c16394e48a36f89e49d509a5f2547e9815178562799c +size 7567 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_1_en.png new file mode 100644 index 0000000000..fd64fcd38b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97f9c238900766dde4e7a2db1810cda11d453bb13b41213e5cf2b60548ebf47c +size 7205 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_2_en.png new file mode 100644 index 0000000000..2deddffe3a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_SelectionModeTopBar_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2721dc2ee00dce017522144f9ab7643817fc4abcd49e8d41f62fda04d2cec1f9 +size 7448 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Day_0_en.png new file mode 100644 index 0000000000..8bce40ff14 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d8d6dab0b68be7a3feec477fd6026a0a6e4e8aeebd8c67adcec52270669420e +size 53355 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Night_0_en.png new file mode 100644 index 0000000000..d4f3bff641 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewSelectionMode_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39b9f7d1de15cb084e46996755d9f1b2be25278eb90070d0983d67146dc55edb +size 54377 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png index bf4223a9cf..ed28276533 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b164aa5e1a4e8115aede74944e450dfd3abe33d13f6b91bc27745d87679a7f9 -size 13080 +oid sha256:bf1e3ce457d2e28ee929ade5bfc1abfc0a62ef2dc8443824300d15f78e843b75 +size 11233 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png index 309ace3cff..eacac1fc43 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99beabe21f4f1dad2673e96631dd6756b78cb1c1495c7a7ba8d44257b593e007 -size 10999 +oid sha256:90452c32320a3eaad1c520885c487dc6242e1333f2084940c85e8f4060940b1e +size 9166 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png index c186f08d29..a74397edcd 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bbd7ca482fe8e846b2748d25e429a5fffd2c0ecc88014942efb90ea312cb298 -size 34413 +oid sha256:c3959f54f33bb8302839691e8c1a6f24b5b192c318bc4767636a27bb2578061d +size 32623 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png index cb7871cabf..9e671f14cf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18eef832d5c341dbc7d9c6c5495464dd9bfbd5c9355c3fe74384e6b1fdb0fe40 -size 32481 +oid sha256:95cd751b1fcdd38cd38b5dd0335f0d157b9a0dfd45ca6a7eebf4ae55488f0c84 +size 30661 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png index 16399a868c..3ab43d84e3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ebb9b7f9c8e657d8e5a7fae6a133449fbbbb7529260dc4feb6c5da176eb1103 -size 36481 +oid sha256:91c711395cfbca04b7b41c23d01012870a97a4b4b0291d6dc81c0949e60d98e8 +size 34783 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png index 4af9e7c6f5..149038c4f5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ba018ba755b9925df764eec9a42a0f18853bce9944f153e853dda5427267e54 -size 12481 +oid sha256:71b4ecc3a18de7be9eb8ef453852f6f9a5c44ae8edb0a14cdf09a4a240c3faab +size 10703 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png index ac3d4a319d..9287ee04f3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6293fe8bfd160716a014932d7b9348fcad9cfad08a0c747bd49cf5922a5b685d -size 10732 +oid sha256:6e4a4c20eb92cf93d5e7a76973b46863ed79b32e36c9d970b311b9681a382b23 +size 8957 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png index 6ecca16bce..0fd706f24c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0653e38a43b99d9b07e41cdccc1bdad0e1d22a1449fea87f3909c061dffeea1e -size 34221 +oid sha256:94998fe24668f8dde57a1cdfb6e8e8cc25f1d74d8b3eb69fccf4db9067667a86 +size 32446 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png index dd4fa7ec73..8fa06609d9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:396c1b84d656baefa9161221bbc82343d55552dd7ea6aa44d87b4fc1a294a769 -size 32436 +oid sha256:b77e1b57b374991fdfd48c2ed42f1eefbbc3afeeda6c802831d9ab5949518581 +size 30666 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png index 5178aaf31c..1b7bc1040d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5e5d889237d4e539e595c0dffd0f565b47b11257a8ef592567de0d302116f92 -size 36737 +oid sha256:a8d56ee6a21f4e9dfe20c64b6cc55eb94bb8b281aba375be31904660b4fa9e88 +size 35101 From 7ef93b51e481ee4cd55d46ae3a3c541562b747c9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Oct 2026 20:47:57 +0200 Subject: [PATCH 17/47] Use the Element Creations license header on the new selection files --- .../android/features/messages/impl/timeline/SelectionState.kt | 2 +- .../messages/impl/timeline/components/SelectionIndicator.kt | 2 +- .../features/messages/impl/topbars/SelectionModeTopBar.kt | 2 +- .../features/messages/impl/timeline/SelectionActionTest.kt | 2 +- .../features/messages/impl/timeline/SelectionStateTest.kt | 2 +- .../features/messages/impl/timeline/TimelineStateTest.kt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt index 7a9f073bc0..b252dfd10e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt index 86261057c8..f405b79e69 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 1d12699277..50b14e5958 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt index 4939ec3a50..1d7155f742 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt index ea45813a10..bb8f66630d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt index 3ee818e438..8e3cb997c2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 New Vector Ltd. + * 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. From d4310cb6e9aa286a6889594111073daadfa1ffe7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Oct 2026 20:48:05 +0200 Subject: [PATCH 18/47] Use toImmutableSet instead of toPersistentSet --- .../io/element/android/features/messages/impl/MessagesView.kt | 4 ++-- .../android/features/messages/impl/timeline/SelectionState.kt | 4 ++-- .../features/messages/impl/timeline/TimelinePresenter.kt | 4 ++-- .../features/messages/impl/timeline/SelectionStateTest.kt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 92c91dfee5..50b90be650 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -142,7 +142,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentSet +import kotlinx.collections.immutable.toImmutableSet import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -774,7 +774,7 @@ internal fun MessagesViewSelectionModePreview() = ElementPreview { .filter { SelectionAction.Forward.canApplyTo(it) } .take(2) .mapNotNull { it.eventId } - .toPersistentSet() + .toImmutableSet() MessagesView( state = aMessagesState( timelineState = aTimelineState( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt index b252dfd10e..3c44839468 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -16,7 +16,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toPersistentSet +import kotlinx.collections.immutable.toImmutableSet /** Maximum number of messages that can be selected at once. */ const val MAX_SELECTION_COUNT = 10 @@ -57,7 +57,7 @@ fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) */ fun SelectionState.deselect(eventIds: Set): SelectionState = when (this) { SelectionState.Disabled -> this - is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toPersistentSet()) + is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toImmutableSet()) } /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 0b70088336..a7006985a5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -75,7 +75,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentSet +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine @@ -285,7 +285,7 @@ class TimelinePresenter( snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected)) current } - else -> current.copy(selectedEventIds = (selected + event.eventId).toPersistentSet()) + else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet()) } } is TimelineEvent.ExitSelectionMode -> { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt index bb8f66630d..920b3bdaae 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt @@ -16,7 +16,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentSet +import kotlinx.collections.immutable.toImmutableSet import org.junit.Test class SelectionStateTest { @@ -84,6 +84,6 @@ class SelectionStateTest { private fun anActiveSelection(eventIds: List = emptyList()) = SelectionState.Active( action = SelectionAction.Forward, - selectedEventIds = eventIds.toPersistentSet(), + selectedEventIds = eventIds.toImmutableSet(), ) } From 9b2dcd4b260e43b10883ec373c2a1811817e8a6f Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Oct 2026 20:48:14 +0200 Subject: [PATCH 19/47] Fix the Konsist preview naming for the selection previews --- .../features/messages/impl/topbars/SelectionModeTopBar.kt | 4 ++-- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + .../io/element/android/tests/konsist/KonsistPreviewTest.kt | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 50b14e5958..5cd2b15bda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -63,7 +63,7 @@ fun SelectionModeTopBar( @PreviewsDayNight @Composable internal fun SelectionModeTopBarPreview( - @PreviewParameter(SelectionCountProvider::class) count: Int, + @PreviewParameter(SelectionCountPreviewParam::class) count: Int, ) = ElementPreview { SelectionModeTopBar( selectedCount = count, @@ -73,6 +73,6 @@ internal fun SelectionModeTopBarPreview( ) } -internal class SelectionCountProvider : PreviewParameterProvider { +internal class SelectionCountPreviewParam : PreviewParameterProvider { override val values = sequenceOf(0, 1, 5) } diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 7c6eb2b630..5fa83ac198 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -66,6 +66,7 @@ class KonsistClassNameTest { "EditableAvatarViewUriPreviewParam", "LoginModeViewErrorPreviewParam", "OverlapRatioPreviewParam", + "SelectionCountPreviewParam", "TextFileContentPreviewParam", "HtmlMessageContentPreviewParam", ) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 339ba1023a..f01cef9791 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -110,6 +110,7 @@ class KonsistPreviewTest { "MessageComposerViewVoicePreview", "MessagesReactionButtonAddPreview", "MessagesReactionButtonExtraPreview", + "MessagesViewSelectionModePreview", "MessagesViewWithIdentityChangePreview", "PendingMemberRowWithLongNamePreview", "PinUnlockViewInAppPreview", @@ -133,6 +134,7 @@ class KonsistPreviewTest { "SasEmojisPreview", "SecureBackupSetupViewChangePreview", "SelectedUserCannotRemovePreview", + "SelectionIndicatorSelectedPreview", "SpaceMembersViewNoHeroesPreview", "SyncStateViewServerUnreachablePreview", "TextComposerAddCaptionPreview", @@ -179,6 +181,7 @@ class KonsistPreviewTest { "TimelineItemVoiceViewScanningContentPreview", "TimelineItemVoiceViewUnifiedPreview", "TimelineViewMessageShieldPreview", + "TimelineViewSelectionModePreview", "TimelineViewWithReadMarkerBothIndicatorsPreview", "TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview", "UserAvatarColorsPreview", From cb501f3b5f0186ac046b84f6e114a25b7bd60db9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Oct 2026 20:48:24 +0200 Subject: [PATCH 20/47] Fix the Detekt issues on the timeline selection code --- .../messages/impl/timeline/components/TimelineItemRow.kt | 2 +- .../impl/timeline/model/TimelineItemSelectionData.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 32cef08ea7..bc19f239cc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -194,7 +194,7 @@ internal fun TimelineItemRow( layout(width = width, height = height) { contentPlaceable.place(0, 0) if (indicatorPlaceable != null) { - val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: (height / 2) + val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: height / 2 val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt index 60a30d5c73..eab6a2523f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt @@ -12,12 +12,12 @@ package io.element.android.features.messages.impl.timeline.model * * @param isSelectionModeActive whether the timeline is currently in selection mode. Drives * behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress]. - * @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in). - * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] - * during the enter/exit animation. * @param isSelected whether this specific row's event is part of the current selection. * @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for. * Rows which cannot be selected render no indicator, and their content does not slide. + * @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in). + * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] + * during the enter/exit animation. */ data class TimelineItemSelectionData( val isSelectionModeActive: Boolean = false, From 752b0c59b6e40f804d0499f723327089bd84cd84 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 5 Oct 2026 20:48:35 +0200 Subject: [PATCH 21/47] Move the composable lambda parameter last in TimelineItemRowContent --- .../messages/impl/timeline/components/TimelineItemRow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index bc19f239cc..d729ee6532 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -241,9 +241,9 @@ private fun TimelineItemRowContent( onJoinCallClick: (isAudioCall: Boolean) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, - eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, modifier: Modifier = Modifier, selectionContentOffset: Dp = 0.dp, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { Box(modifier) { when (timelineItem) { From f2310cc53063de2bc149e119f68ccaae3cf147c9 Mon Sep 17 00:00:00 2001 From: mxandreas Date: Tue, 6 Oct 2026 09:53:26 +0300 Subject: [PATCH 22/47] Made the hooks of room creation and security & privacy screen more generic. --- enterprise | 2 +- .../configureroom/ConfigureRoomExtension.kt | 14 ++++++ .../configureroom/ConfigureRoomPresenter.kt | 11 ++--- .../NoopConfigureRoomExtension.kt | 16 +++++++ .../impl/ConfigureRoomPresenterTest.kt | 45 ++----------------- .../api/SessionEnterpriseService.kt | 6 --- .../impl/DefaultSessionEnterpriseService.kt | 1 - .../test/FakeSessionEnterpriseService.kt | 5 --- .../root/NoopSecurityAndPrivacyExtension.kt | 16 +++++++ .../impl/root/SecurityAndPrivacyExtension.kt | 14 ++++++ .../impl/root/SecurityAndPrivacyPresenter.kt | 7 +-- .../impl/root/SecurityAndPrivacyState.kt | 6 +-- .../SecurityAndPrivacyStatePreviewParam.kt | 8 +--- .../root/SecurityAndPrivacyPresenterTest.kt | 45 +------------------ 14 files changed, 82 insertions(+), 114 deletions(-) create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt create mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt create mode 100644 features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt create mode 100644 features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt diff --git a/enterprise b/enterprise index d25d658acb..eb05f3b813 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit d25d658acbee55e25d20a2f22f411040adfb078c +Subproject commit eb05f3b8131dead9dceca483a1372d8bb4306ab1 diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt new file mode 100644 index 0000000000..0b2f162e8e --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt @@ -0,0 +1,14 @@ +/* + * 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.createroom.impl.configureroom + +/** Lets a build-specific implementation adjust what the configure room screen offers. */ +interface ConfigureRoomExtension { + /** Whether the public join rule may be offered. */ + suspend fun isPublicAccessAllowed(): Boolean +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 86cd67546e..57ae875771 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -76,6 +76,7 @@ class ConfigureRoomPresenter( private val roomAliasHelper: RoomAliasHelper, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val sessionEnterpriseService: SessionEnterpriseService, + private val configureRoomExtension: ConfigureRoomExtension, ) : Presenter { @AssistedFactory interface Factory { @@ -93,8 +94,8 @@ class ConfigureRoomPresenter( val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(initial = false) - val arePublicRoomsDisabled by produceState(false) { - value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + val isPublicAccessAllowed by produceState(true) { + value = configureRoomExtension.isPublicAccessAllowed() } val roomAddressValidity = remember { mutableStateOf(RoomAddressValidity.Unknown) @@ -149,13 +150,13 @@ class ConfigureRoomPresenter( // 1. If we are creating a space. // 2. If it has a parent space. // 3. If knocking is enabled. - // 4. If the homeserver forbids public rooms. + // 4. If public access is allowed. val parentSpace = createRoomConfig.parentSpace - val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) { + val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, isPublicAccessAllowed) { when { isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( - JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled }, + JoinRuleItem.PublicVisibility.Public.takeIf { isPublicAccessAllowed }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.Private, ).toImmutableList() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt new file mode 100644 index 0000000000..8735ffde33 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt @@ -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.createroom.impl.configureroom + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class NoopConfigureRoomExtension : ConfigureRoomExtension { + override suspend fun isPublicAccessAllowed(): Boolean = true +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index ebd3cdcdc2..5a247a78d8 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.createroom.impl.configureroom.ConfigureRoomSt import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore import io.element.android.features.createroom.impl.configureroom.JoinRuleItem +import io.element.android.features.createroom.impl.configureroom.NoopConfigureRoomExtension import io.element.android.features.createroom.impl.configureroom.RoomAddress import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState import io.element.android.features.enterprise.test.FakeSessionEnterpriseService @@ -57,7 +58,6 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -523,10 +523,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() { @Test fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { true }, - arePublicRoomsDisabledResult = { false }, - ) + val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) val createRoomLambda = lambdaRecorder> { Result.success(A_ROOM_ID) } val matrixClient = createMatrixClient().apply { createRoomResult = createRoomLambda @@ -548,38 +545,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() { } } - @Test - fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest { - val presenter = createConfigureRoomPresenter( - sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { true }, - ), - ) - presenter.test { - val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private) - val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last() - assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest { - val presenter = createConfigureRoomPresenter( - isSpace = true, - sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { true }, - ), - ) - presenter.test { - val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last() - assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) - cancelAndIgnoreRemainingEvents() - } - } - private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { skipItems(1) return awaitItem() @@ -615,10 +580,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() { permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { false }, - ), + sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), ) = ConfigureRoomPresenter( isSpace = isSpace, initialParentSpaceId = initialParenSpaceId, @@ -634,5 +596,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() { ), mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, sessionEnterpriseService = sessionEnterpriseService, + configureRoomExtension = NoopConfigureRoomExtension(), ) } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index aff9a3ae5c..94f455f2ff 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -29,10 +29,4 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean - - /** - * Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one, - * nor to make an existing room or space public. - */ - suspend fun arePublicRoomsDisabledByHomeserver(): Boolean } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt index 8a528257a6..3ac10d8a08 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -18,5 +18,4 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService { override suspend fun tweakMasUrl(url: String): String = url override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false - override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt index a461b87c95..e198fca867 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -16,7 +16,6 @@ class FakeSessionEnterpriseService( private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, - private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() }, ) : SessionEnterpriseService { override suspend fun init() { } @@ -32,8 +31,4 @@ class FakeSessionEnterpriseService( override suspend fun isEncryptionDisabledByHomeserver(): Boolean { return isEncryptionDisabledResult() } - - override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean { - return arePublicRoomsDisabledResult() - } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt new file mode 100644 index 0000000000..3471a48a90 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt @@ -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.securityandprivacy.impl.root + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class NoopSecurityAndPrivacyExtension : SecurityAndPrivacyExtension { + override suspend fun isPublicAccessAllowed(): Boolean = true +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt new file mode 100644 index 0000000000..320d04a48d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt @@ -0,0 +1,14 @@ +/* + * 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.securityandprivacy.impl.root + +/** Lets a build-specific implementation adjust what the security and privacy screen offers. */ +interface SecurityAndPrivacyExtension { + /** Whether the room access may be changed to anyone. */ + suspend fun isPublicAccessAllowed(): Boolean +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index 498e71dd0a..c79401ff1c 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -65,6 +65,7 @@ class SecurityAndPrivacyPresenter( private val room: JoinedRoom, private val featureFlagService: FeatureFlagService, private val sessionEnterpriseService: SessionEnterpriseService, + private val securityAndPrivacyExtension: SecurityAndPrivacyExtension, ) : Presenter { @AssistedFactory interface Factory { @@ -89,8 +90,8 @@ class SecurityAndPrivacyPresenter( value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() } - val arePublicRoomsDisabledByHomeserver by produceState(false) { - value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + val isPublicAccessAllowed by produceState(true) { + value = securityAndPrivacyExtension.isPublicAccessAllowed() } val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } @@ -258,7 +259,7 @@ class SecurityAndPrivacyPresenter( selectableJoinedSpaces = selectableJoinedSpaces, spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, - arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, + isPublicAccessAllowed = isPublicAccessAllowed, eventSink = ::handleEvent, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index 5b61953674..d65493fda9 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -28,7 +28,7 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val isEncryptionDisabledByHomeserver: Boolean, - private val arePublicRoomsDisabledByHomeserver: Boolean, + private val isPublicAccessAllowed: Boolean, val showEnableEncryptionConfirmation: Boolean, private val isKnockEnabled: Boolean, val saveAction: AsyncAction, @@ -42,8 +42,8 @@ data class SecurityAndPrivacyState( // Show Anyone option when: // - Anyone is the current saved value, OR - // - The homeserver allows public rooms - val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver + // - Public access is allowed + val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || isPublicAccessAllowed // Show SpaceMember option in two cases: // - SpaceMember is the current saved value diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt index 45383bf814..44d13c4ca3 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt @@ -37,10 +37,6 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider = emptySet(), spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, isEncryptionDisabledByHomeserver: Boolean = false, - arePublicRoomsDisabledByHomeserver: Boolean = false, + isPublicAccessAllowed: Boolean = true, eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -157,6 +153,6 @@ fun aSecurityAndPrivacyState( selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, - arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, + isPublicAccessAllowed = isPublicAccessAllowed, eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 5a0cf743e2..10669fc15a 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -34,8 +34,6 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom -import io.element.android.tests.testutils.consumeItemsUntilPredicate -import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -1050,10 +1048,7 @@ class SecurityAndPrivacyPresenterTest { @Test fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { true }, - arePublicRoomsDisabledResult = { false }, - ) + val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) val presenter = createSecurityAndPrivacyPresenter( sessionEnterpriseService = sessionEnterpriseService, ) @@ -1065,42 +1060,6 @@ class SecurityAndPrivacyPresenterTest { } } - @Test - fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest { - val presenter = createSecurityAndPrivacyPresenter( - sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { true }, - ), - ) - presenter.test { - consumeItemsUntilPredicate { !it.showAnyoneOption } - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest { - val presenter = createSecurityAndPrivacyPresenter( - room = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - roomPermissions = roomPermissions(), - getRoomVisibilityResult = { Result.success(RoomVisibility.Public) }, - initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public) - ), - ), - sessionEnterpriseService = FakeSessionEnterpriseService( - isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { true }, - ), - ) - presenter.test { - val states = consumeItemsUntilTimeout() - assertThat(states).isNotEmpty() - assertThat(states.all { it.showAnyoneOption }).isTrue() - } - } - private fun roomPermissions( canChangeRoomAccess: Boolean = true, canChangeHistoryVisibility: Boolean = true, @@ -1141,7 +1100,6 @@ class SecurityAndPrivacyPresenterTest { spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( isEncryptionDisabledResult = { false }, - arePublicRoomsDisabledResult = { false }, ) ): SecurityAndPrivacyPresenter { return SecurityAndPrivacyPresenter( @@ -1151,6 +1109,7 @@ class SecurityAndPrivacyPresenterTest { featureFlagService = featureFlagService, spaceSelectionStateHolder = spaceSelectionStateHolder, sessionEnterpriseService = sessionEnterpriseService, + securityAndPrivacyExtension = NoopSecurityAndPrivacyExtension(), ) } } From 4d6bc8716275e00c049723c947141f7070fb4729 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:16:14 +0000 Subject: [PATCH 23/47] Update dependency io.element.android:element-call-embedded to v0.27.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a1ebfbcc1e..604eda574a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.26.0" +element_call_embedded = "io.element.android:element-call-embedded:0.27.0" # The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } element_call_api = { module = "io.element.android:element-call-api" } From add7099e4496975fc17ddf467fc1c709b6348116 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:30:51 +0000 Subject: [PATCH 24/47] Update dependency net.zetetic:sqlcipher-android to v4.19.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b71e6bc96..30cc4febaa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.19.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.19.1" sqlite = "androidx.sqlite:sqlite-ktx:2.7.1" unifiedpush = "org.unifiedpush.android:connector:3.3.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" From 5631a91e282cecd1bda1086f007c89a667449436 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 7 Oct 2026 12:25:43 +0200 Subject: [PATCH 25/47] Update the multi-select feature flag description --- .../element/android/libraries/featureflag/api/FeatureFlags.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 7939c2bda2..0024ce1f9a 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -157,7 +157,7 @@ enum class FeatureFlags( MessageMultiSelect( key = "feature.message_multi_select", title = "Multi-select messages", - description = "Adds a Select action to the message menu to pick several messages at once.", + description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.", defaultValue = { false }, isFinished = false, isInLabs = false, From c7937355110a86ba59499c8119d4b50a2c8fa411 Mon Sep 17 00:00:00 2001 From: gradle-update-robot Date: Thu, 8 Oct 2026 00:48:16 +0000 Subject: [PATCH 26/47] Update Gradle Wrapper from 9.8.0 to 9.8.1 Signed-off-by: gradle-update-robot --- gradle/wrapper/gradle-wrapper.jar | Bin 47623 -> 48749 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 5097068a8d375f5bf0d15693fb5b3615c972a801..9fa7f288283f81eec4952784c271abd7e0938dee 100644 GIT binary patch delta 39361 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 G?0*2IFc^CP delta 38292 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 dFCnI4R Date: Thu, 8 Oct 2026 09:32:39 +0200 Subject: [PATCH 27/47] Fix room list not scrollable in room select screen when the account switch is hidden The nested scroll connection of the account switch section was always attached. When the section is not displayed (single account, Forward mode, or search active), its height offset limit is never set and stays at -Float.MAX_VALUE, so the connection consumed all the scroll events and the room list could not be scrolled. Only attach the connection when the account switch section is displayed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../roomselect/impl/RoomSelectView.kt | 9 ++++- .../roomselect/impl/RoomSelectViewTest.kt | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 5865d4416a..19798d854c 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -177,7 +177,14 @@ fun RoomSelectView( Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) - .nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) + .then( + // Only when the account switch section is displayed, else its unknown height would consume all the scroll events + if (state.showAccountSwitch && !state.isSearchActive) { + Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) + } else { + Modifier + } + ) ) { SearchBar( modifier = Modifier diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index 543d20b521..71284e6ec5 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -13,8 +13,12 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId @@ -29,6 +33,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.junit.Test import org.robolectric.annotation.Config @@ -123,6 +128,33 @@ class RoomSelectViewTest : RobolectricTest() { onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() } + @Test + fun `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + + @Test + fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + @Test fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { setRoomSelectView( @@ -150,3 +182,10 @@ private fun AndroidComposeUiTest.setRoomSelectView( ) } } + +private fun aLongRoomList() = List(30) { index -> + aSelectRoomInfo( + roomId = RoomId("!room$index:domain"), + name = "Room $index", + ) +}.toImmutableList() From 920e37d09706e4cb37a7672a5e646bfd5be2b0dc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Oct 2026 09:35:06 +0200 Subject: [PATCH 28/47] Scroll the rooms header with the search results in room select screen Move the "Rooms" section header inside the LazyColumn of the search results, so that it scrolls with the list instead of staying fixed above it, as it is done for the room list when the search is not active. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../libraries/roomselect/impl/RoomSelectView.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 19798d854c..1c7d59eed7 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -201,16 +201,18 @@ fun RoomSelectView( resultState = state.resultState, showBackButton = false, ) { summaries -> - if (summaries.isNotEmpty()) { - ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), - hasDivider = false, - ) - } LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, ) { + if (summaries.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } + } item { SelectedRoomsHelper( selectedRooms = state.selectedRooms, From c4173ae185cd6dda06ca679b1157fce308815335 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 8 Oct 2026 17:42:08 +0200 Subject: [PATCH 29/47] Fix the row selection documentation about sliding content --- .../messages/impl/timeline/model/TimelineItemSelectionData.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt index eab6a2523f..be2c39c6bf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt @@ -14,7 +14,8 @@ package io.element.android.features.messages.impl.timeline.model * behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress]. * @param isSelected whether this specific row's event is part of the current selection. * @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for. - * Rows which cannot be selected render no indicator, and their content does not slide. + * Rows which cannot be selected render no indicator and ignore clicks, but their content still slides: sliding only depends on the row being a + * start-aligned bubble, so that the bubbles stay aligned with each other. * @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in). * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] * during the enter/exit animation. From fe419890a586c07dd01a2ff381fe71993787172f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:45:14 +0000 Subject: [PATCH 30/47] Update dependency io.sentry:sentry-android to v8.59.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84028f6ce4..9fc6fc42d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -227,7 +227,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.60.7" -sentry = "io.sentry:sentry-android:8.57.0" +sentry = "io.sentry:sentry-android:8.59.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0" From a53bf586e98486f8e8105b4633be717cd5bfbe5a Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 8 Oct 2026 16:04:27 +0000 Subject: [PATCH 31/47] Update screenshots --- .../libraries.roomselect.impl_RoomSelectView_Day_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_4_en.png | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png index a3efa82e01..7ac7728536 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4614a485e74a734ae29d28588f482e38124543c16169443859bbfb1052fa348 -size 36016 +oid sha256:96da17f60cf84b6e5052d9753e3e81fc6f27e1ca78bbadb4501c9e4c864b6eeb +size 34329 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png index 1bd6536c16..48e6dbced5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a8c5f7dd955e112736c4eeb6a0f9a52cda2f08264f50567d13e2bca8ed1fcf5 -size 33921 +oid sha256:da7a0e6c7ed39c6d1c8f900e32d6139da70333bac1356b017148f68ea7c1dcbf +size 32126 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png index 566292b739..19362d4a71 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:23c03d8ceeb921a6fe5ec71764c71e6cb6623ae25b6e455cd4e39e673732bab7 -size 37812 +oid sha256:c234a4c6b0c3219e4cfc6330601b15f7dc1db385b4024c18bd7f3376c27f7122 +size 36112 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png index 660cfc382e..c4d89d3fb0 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f68669e01f3ad0e984e70876cbcb2fe54fdbe78fa579000919bb025fab05ab27 -size 35943 +oid sha256:f472811552d7499bd0acd7c929224a643957816cb4122c1a95ceaf5a7b32f1f3 +size 34198 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png index 5e079df0be..60b3f75e62 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:23a17d6f9e1e44452d4f18b31ccfd93070baba947d7d5b5d5dce1accc737c964 -size 34066 +oid sha256:d722f376b5cc87f8a11be2d42347fbf90f31b04699f9ec6373ed068454545372 +size 32336 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png index 4b82ca3c80..c27956224c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c6b20e6f50dc0a3a7f9086252fb1be322602cfed48c0750791ad24d88feb9ca1 -size 38032 +oid sha256:f45a3dff7b5b669c643394ba12e0df55344ff76177030d1ae1de57275c57c4c6 +size 36401 From 1564de9b6eac80aa9c0d8e8edc65178688373261 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 9 Oct 2026 08:30:43 +0200 Subject: [PATCH 32/47] Iterate on unread counts again (#7869) * Fix timestamp color to match the designs. * Don't limit the call and unread row height to 16dp: the font size is not fixed and it might display components in a broken state * Ensure the alignment works * Remove `SemanticColors.unreadIndicator` and use `.iconAccentPrimary` and `.textActionAccent` instead * Simplify and document `UnreadIndicatorAtom` * Use `fontBodySmMedium` for the font size in the unread indicator * Update screenshots --------- Co-authored-by: ElementBot --- .../home/impl/components/RoomSummaryRow.kt | 13 +-- .../impl/threads/list/ThreadsListView.kt | 11 +-- .../messages/impl/timeline/TimelineView.kt | 1 - .../designsystem/atomic/atoms/CounterAtom.kt | 80 +++++++++++-------- .../atomic/atoms/UnreadIndicatorAtom.kt | 52 ++++++------ .../designsystem/theme/ColorAliases.kt | 4 - .../components/HorizontalFloatingToolbar.kt | 8 +- .../matrix/ui/components/SpaceRoomItemView.kt | 6 +- ...pl.components_RoomSummaryRow_Day_10_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_15_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_16_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_25_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_27_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_28_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_31_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_33_en.png | 4 +- ...pl.components_RoomSummaryRow_Day_34_en.png | 4 +- ...mpl.components_RoomSummaryRow_Day_9_en.png | 4 +- ....components_RoomSummaryRow_Night_10_en.png | 4 +- ....components_RoomSummaryRow_Night_13_en.png | 4 +- ....components_RoomSummaryRow_Night_15_en.png | 4 +- ....components_RoomSummaryRow_Night_16_en.png | 4 +- ....components_RoomSummaryRow_Night_25_en.png | 4 +- ....components_RoomSummaryRow_Night_26_en.png | 4 +- ....components_RoomSummaryRow_Night_27_en.png | 4 +- ....components_RoomSummaryRow_Night_28_en.png | 4 +- ....components_RoomSummaryRow_Night_31_en.png | 4 +- ....components_RoomSummaryRow_Night_32_en.png | 4 +- ....components_RoomSummaryRow_Night_33_en.png | 4 +- ....components_RoomSummaryRow_Night_34_en.png | 4 +- ...l.components_RoomSummaryRow_Night_7_en.png | 4 +- ...l.components_RoomSummaryRow_Night_9_en.png | 4 +- ...stem.atomic.atoms_CounterAtom_Day_0_en.png | 4 +- ...em.atomic.atoms_CounterAtom_Night_0_en.png | 4 +- ...mic.atoms_UnreadIndicatorAtom_Day_0_en.png | 4 +- ...c.atoms_UnreadIndicatorAtom_Night_0_en.png | 4 +- ...orizontalFloatingToolbarNoFab_Day_0_en.png | 4 +- ...izontalFloatingToolbarNoFab_Night_0_en.png | 4 +- ...nts_HorizontalFloatingToolbar_Day_0_en.png | 4 +- ...s_HorizontalFloatingToolbar_Night_0_en.png | 4 +- ...e.components_NavigationBar_App_Bars_en.png | 4 +- ...signsystem.theme_ColorAliases_Day_0_en.png | 4 +- ...gnsystem.theme_ColorAliases_Night_0_en.png | 4 +- 43 files changed, 161 insertions(+), 154 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 2ab529ed43..1f8fe4f2d2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomName -import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.user.DisplayedStatus @@ -244,7 +243,7 @@ private fun NameAndTimestampRow( text = timestamp ?: "", style = ElementTheme.typography.fontBodySmMedium, color = if (isHighlighted) { - ElementTheme.colors.unreadIndicator + ElementTheme.colors.textActionAccent } else { ElementTheme.colors.roomListRoomMessageDate }, @@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow( // Call and unread Row( modifier = Modifier - .height(16.dp) // Used to force this line to be read aloud earlier than the latest event when using Talkback .zIndex(-1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary + val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary if (room.hasRoomCall) { OnGoingCallIcon( color = tint, @@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow( overflow = TextOverflow.Ellipsis ) if (!isInviteSeen) { - UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator, - count = 0, - ) + UnreadIndicatorAtom(count = 0) } } } @@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() { modifier = Modifier.size(16.dp), contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), imageVector = CompoundIcons.Mention(), - tint = ElementTheme.colors.unreadIndicator, + tint = ElementTheme.colors.iconAccentPrimary, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt index d72d3d4b10..a47cdc091f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -264,11 +264,12 @@ private fun ThreadListItemRow( ) } - UnreadIndicatorAtom( - size = 14.dp, - isVisible = hasUnreadNotifications, - count = 0, - ) + if (hasUnreadNotifications) { + UnreadIndicatorAtom( + dotIndicatorSize = 14.dp, + count = 0, + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index b3c00c8356..7d1a51c993 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -597,7 +597,6 @@ private fun JumpToPositionButton( modifier = Modifier .align(dotAlignment) .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, - color = ElementTheme.colors.iconSuccessPrimary, border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), count = 0, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt index ef6a71cc90..178b75b777 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt @@ -8,26 +8,32 @@ package io.element.android.libraries.designsystem.atomic.atoms +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.Text private const val MAX_COUNT = 99 @@ -54,7 +60,7 @@ fun CounterAtom( modifier: Modifier = Modifier, containerColor: Color? = null, contentColor: Color? = null, - contentPadding: PaddingValues = PaddingValues.Zero, + contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp), textStyle: TextStyle = CounterAtomDefaults.textStyle, dotSize: Dp = 12.0.dp, isCritical: Boolean = false, @@ -65,40 +71,50 @@ fun CounterAtom( else -> MAX_COUNT_STRING } - val minSingleDigitSize = if (countAsText?.length == 1) { - val textMeasurer = rememberTextMeasurer() - // Measure the maximum count string size - val textLayoutResult = textMeasurer.measure( - text = countAsText, + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val minTextSize = remember(density.density, textStyle) { + val result = textMeasurer.measure( + text = MAX_COUNT_STRING, style = textStyle ) - val textSize = textLayoutResult.size - maxOf(textSize.width, textSize.height) + with(density) { + minOf(result.size.width, result.size.height).toDp() + } + } + val sizeModifier = if (countAsText?.length == 1) { + Modifier.size(minTextSize) } else { - 0 + Modifier.sizeIn(minHeight = minTextSize) } - androidx.compose.material3.Badge( - modifier = modifier.then( - if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier - ), - containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary, - contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary, - content = countAsText?.let { - @Composable { - Text( - modifier = Modifier.sizeIn( - minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp), - minHeight = minSingleDigitSize.toDp() - ).padding(contentPadding), - text = countAsText, - style = textStyle, - color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, - textAlign = TextAlign.Center, - ) - } + val extraPadding = if (countAsText?.length == 1) { + contentPadding + } else { + contentPadding + PaddingValues(horizontal = 4.dp) + } + val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary + + Box( + modifier = modifier + .clip(RoundedCornerShape(100)) + .background(backgroundColor) + .then( + if (countAsText == null) Modifier.size(dotSize) else Modifier + ) + ) { + countAsText?.let { + Text( + modifier = sizeModifier + .align(Alignment.Center) + .padding(extraPadding), + text = countAsText, + style = textStyle, + color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, + textAlign = TextAlign.Center, + ) } - ) + } } object CounterAtomDefaults { @@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview { Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { CounterAtom(count = 0) CounterAtom(count = 4) - CounterAtom(count = 99) + CounterAtom(count = 10) CounterAtom(count = 100) CounterAtom(count = 4, isCritical = true) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt index 332621bba5..606d57daa5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -27,35 +26,42 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.unreadIndicator +/** + * A small indicator to show the number of unread messages or notifications. + * + * @param count The number of unread messages or notifications. If null, the indicator will be hidden, if 0, a notification dot will be shown. + * @param modifier The modifier to be applied to the indicator. + * @param dotIndicatorSize The size of the dot indicator. + * @param color The background color of the indicator. + * @param contentDescription The content description for accessibility. + * @param border The border to be applied to the indicator. + * @param contentPadding The padding inside the indicator. + */ @Composable fun UnreadIndicatorAtom( + count: Long?, modifier: Modifier = Modifier, - size: Dp = 12.dp, - count: Long? = null, - color: Color = ElementTheme.colors.unreadIndicator, - isVisible: Boolean = true, + dotIndicatorSize: Dp = 12.dp, + color: Color = ElementTheme.colors.iconAccentPrimary, contentDescription: String? = null, border: BorderStroke? = null, contentPadding: PaddingValues = PaddingValues.Zero, ) { - when { - !isVisible -> Spacer(modifier = modifier.size(size)) - else -> count?.let { - CounterAtom( - count = count.toInt(), - modifier = modifier - .semantics { - contentDescription?.let { this.contentDescription = it } - } - .then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier), - containerColor = color, - contentColor = ElementTheme.colors.bgCanvasDefault, - textStyle = ElementTheme.typography.fontBodySmMedium, - contentPadding = contentPadding, - ) - } + count?.let { + CounterAtom( + count = count.toInt(), + modifier = modifier + .semantics { + contentDescription?.let { this.contentDescription = it } + } + .then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier) + .then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier), + containerColor = color, + contentColor = ElementTheme.colors.bgCanvasDefault, + textStyle = ElementTheme.typography.fontBodySmMedium, + contentPadding = contentPadding, + ) } } @@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview { UnreadIndicatorAtom(count = null) UnreadIndicatorAtom(count = 0) UnreadIndicatorAtom(count = 1) - UnreadIndicatorAtom(count = 10) + UnreadIndicatorAtom(count = 20) UnreadIndicatorAtom(count = 99) UnreadIndicatorAtom(count = 999) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt index 80ef3b9b26..3e0bb4059c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage val SemanticColors.roomListRoomMessageDate get() = textSecondary -val SemanticColors.unreadIndicator - get() = iconAccentTertiary - val SemanticColors.placeholderBackground get() = bgSubtleSecondary @@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview { "roomListRoomName" to ElementTheme.colors.roomListRoomName, "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage, "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate, - "unreadIndicator" to ElementTheme.colors.unreadIndicator, "placeholderBackground" to ElementTheme.colors.placeholderBackground, "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt index 8d399e88ff..cf881d9b93 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip import io.element.android.libraries.designsystem.components.tooltip.TooltipBox import io.element.android.libraries.designsystem.preview.ElementPreview @@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem( BadgedBox( badge = { counter?.let { - CounterAtom( - count = it, + UnreadIndicatorAtom( + count = it.toLong(), // Tweak the offset so it's centered on the icon modifier = Modifier.offset(x = (-16).dp, y = 10.dp), - contentPadding = PaddingValues(vertical = 0.5.dp), - textStyle = ElementTheme.typography.fontBodySmMedium, ) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt index 49dee6e13a..aac7b4015d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -44,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility @@ -168,10 +167,7 @@ private fun NameAndIndicatorRow( overflow = TextOverflow.Ellipsis ) if (showIndicator) { - UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator, - count = 0, - ) + UnreadIndicatorAtom(count = 0) } } } diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_10_en.png index e3e7daa5c8..ba9af2c43a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9859e6965d26dd77d1547dfc2d55a2e6eb2fb3bea0945597e53cf98ed10c0abf -size 14236 +oid sha256:a3e976c494b689084fd9c5a1b3b3b73e727f849cbe0337bb7a6f7e2b8f83742d +size 14249 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_15_en.png index d8e9b663cd..690cde8876 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb43cefd622de742e766c189d72046c3440da187db24c652c9078d238d196980 -size 15588 +oid sha256:7e7d2fadf022d81b799ff00da1d2de459b659d4843d06d0c7f556be6d708fe8e +size 15556 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_16_en.png index 7a0ed1d8d7..3c1b5273a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b388170111334792576248dbb8fdf6e8deb02221ece0d787575b74c1f6762501 -size 16168 +oid sha256:33c5cb6d81280bd4bd75d8e740e708678f5f4ae1f4d3627abf3b3c96aef31eae +size 16185 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_25_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_25_en.png index 447baf82a8..da97494dd0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_25_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_25_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:130b911c71f30a67287dcece24acddc887821211ab47073b47bc2774309cb4d1 -size 16383 +oid sha256:56dbbbe5a435d515a69689403336a148b858c52c2119d3287dd31888b9aa01c4 +size 16360 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_27_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_27_en.png index 4bdfaca2f6..f6007a0a29 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_27_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_27_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b55351815f4d09e3c41200efc52526d9fdfc7539982a518ab9be7991ab67878 -size 14386 +oid sha256:0a7b849e69c9d62715b40e2d2bb41581d65a49eb723399692d4cc5cc3ed21f59 +size 14347 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_28_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_28_en.png index 6f7766a952..5093ffc7a4 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_28_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_28_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a416a35abc2d4a72ebffe1785e7e9cc6648bf738063d23995308e413e0fb4fe0 -size 14984 +oid sha256:3f30e0cf1a1d4cfaf8b2acd18d64437306cdb0292c9e663bb3f975e26efec53c +size 14972 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_31_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_31_en.png index 1dda0850be..4b6f04e4ed 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_31_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_31_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72bcc0ebb6e691c2713c2d2f794de44f7fe3ad8250d86693476d774540bc4bf2 -size 18311 +oid sha256:49090081d08b7122952575569a8244a05219416bccfaa0f0a469416145335932 +size 18289 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_33_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_33_en.png index 7dde8858d7..4361a38eb8 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_33_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_33_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:03e3ef187dbc9225f99550dbf907fa914480c34fda07baa6a99b7e30f3429ea8 -size 16315 +oid sha256:069994b76de896259b6c1a4788e2ba2f65b9897812d22dd92c9a7964c060fa1e +size 16278 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_34_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_34_en.png index e0016578fe..88678ed287 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_34_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_34_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:368333ca178db3eb64a84e01ab7a89f28cde4a00ccc223a318b80f5c8f92f25f -size 16906 +oid sha256:70718a7af588a7973805b2e3e02dcecfba11358277be584a3a2951a226693b1c +size 16895 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_9_en.png index a2ddf0d2a7..c17912c861 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:514b2832e917ff8f5eb0d85227a2c1bf95af51bf6c04802e683627eebdb610ac -size 13653 +oid sha256:4472a8ab659e7f141922d118c67b8c8da7087a97c8d580fa38688ab9e33ab5a0 +size 13616 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_10_en.png index c585649226..356e8e8714 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4018a45ae25f04c01851a1c469bd8e3b50d866258edc0065e4800052f00c8e84 -size 13903 +oid sha256:10077d0cf27267cc5dbd183e0fb1eabfbf3cbe8b004e2f57f241c7fc48967348 +size 13876 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_13_en.png index f368ae3625..6400c3092f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1b81666a131dbf1048b9c6f85f213ef0a5bc388f055420b9bef5de5b8538cc3 -size 16793 +oid sha256:e754819ebb65bb3cfa3463c5c85bfbb8b01ca90eadbb31eb9e35982c87d97ac8 +size 16730 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_15_en.png index 446a77def2..6ad9e4038b 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f240b77a06d40be556392b56419a6cbe3dac62993dedac179f916f8cb8ce5daf -size 15188 +oid sha256:7116d480a60fe5be0a5c74516a97cac5a8b4f31cab093908ef84cae3c48be3b9 +size 15151 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_16_en.png index aeffa0b5ae..65a4f08963 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f8d2824b0b69c9ce745e3d3fbc605d7704f69a455f04caf97ccdb6df23c4438 -size 15713 +oid sha256:d7c96411b2fcd24dc4a7e25cbcf13fadc0cd0dab261c4df6121e1eb1a46cb661 +size 15673 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_25_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_25_en.png index 1350c8f0fb..c888367820 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_25_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_25_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24746af6c6c55f9ef3fd356a4a4590a0ebc42b99f7bc039e7867a65fb4e0356e -size 15891 +oid sha256:8ea25e95bae9ae8d13637da22a844ededd30c9c7cf206e1d761b61546b2214d6 +size 15832 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_26_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_26_en.png index 383ef25be2..fc702ee31c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_26_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_26_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7448e6ef2b7af445f1bdc9a673f21df7ccca55bf4d3058bd3f582ec7b2f5887b -size 13743 +oid sha256:453af394204faf4e917cbbc09804451c9ba1ff7638b00dd32818f4914281ac35 +size 13680 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_27_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_27_en.png index 65831979cc..8436b901cc 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_27_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_27_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0fab1b90dfba4cc0ca0bdf803c1f666fd851018ca6127e4220be10be29c64fc -size 14099 +oid sha256:d4a00d311bc8001e3d1f7c8d4476c56ab98b451ceef7c45ffdd9b98ad7cd1cf9 +size 14051 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_28_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_28_en.png index 3f3e613819..b184fdd1bc 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_28_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_28_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0859c306762bebe0eaac9a9765df5093535cfd0ce576ee04f16a750aae5acc77 -size 14618 +oid sha256:060077d3f87a557d6fb792d54238d277e4453ce2d980520f013b30a9d6006680 +size 14570 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_31_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_31_en.png index 2a14f75d19..8391d799e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_31_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_31_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b611c1b5e60af794e1f1ec3a52aca5df0b20f4fdc2642f5a2db8576abe989a8 -size 17684 +oid sha256:1d25eb295e56c4471501a268486f265d9d9d0cc19e4a033601cb8f1eee653344 +size 17625 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_32_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_32_en.png index a786f0f2c8..5f17c827f0 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_32_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_32_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9780edfed58c077d16eb077300e486b2aaa51f79ee55f0e41b15b66960e865eb -size 15552 +oid sha256:330b1a85ce6d14487f04eb8c88af0f1ce0c4224b78fb3f9e1bb1d045f5e77196 +size 15482 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_33_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_33_en.png index 8197908365..dfed8d6e27 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_33_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_33_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40db6467cf882b4d4c3acdb2229ba46b29b1885dfb9963073fd340eb4f5d4521 -size 15899 +oid sha256:ce97fffc3466c381a5d00a56454547b0528c44c61b5b2a9bc391c043289509d7 +size 15845 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_34_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_34_en.png index f9e213f895..af1411849c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_34_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_34_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8111e0b9ab1e9b3f73ed9247317d1cac40c4aeebedc30302793e355204f9c6bc -size 16411 +oid sha256:92484d52527e3befba350fd966c1a4f72124b1a286b09a488446f1e205eacb6a +size 16359 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_7_en.png index a540878f9a..95e61dd653 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5d2c50711f9ec4ce3f72b3d969403211aa528b018b953f55273a5885feb32c7 -size 15008 +oid sha256:b06e107b47e34cbda9a38e312a8da80a0ccde4d3e80195637248a43c682ba574 +size 14946 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_9_en.png index 70b6511300..4d2228d7fb 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomSummaryRow_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a21934e9f961096de8df954d30beea72d97cae4839b54b2ce6bff0d7e7ce10f3 -size 13379 +oid sha256:39f6bac0ea80d740dcd460ce7c6b88fb2307d933012bdd0eafff780ee7ed341f +size 13345 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Day_0_en.png index a4b5cc493f..68a16ffaf0 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4183ed1526f154b7044cff9b02a1e34c9c0a5fdf93232c9a5a1dc980fe0f1388 -size 7935 +oid sha256:ae21d82fbd06d923e63ab37d3965a735478ae804e39a68f66a6450c5d3ffcc17 +size 7871 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Night_0_en.png index 860f6cef22..ed0e457640 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_CounterAtom_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa39f384f766a79316d8f930ba682b5b08876533e067bbd350b3261d36297888 -size 7424 +oid sha256:11216a13b89381bfb95f88d3eeef9d8b9d49df580daddf3f1c4cc5c9ec62f096 +size 7308 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Day_0_en.png index a17548b7f6..9504b667df 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8cbffff27d8e2284a91afcab63419ad77b4b72e2583d5f5f5fe9f3b86c4497a -size 7267 +oid sha256:e4c8f3e4a35a30796e7083a7b1ef8e5ca7fcf1658c636c857db698b322640a15 +size 7495 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Night_0_en.png index d380a94d3e..07b4fb46c2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed6a76df3601c8740d636503aea1173ed711da91fae12935ddd925483b4197ca -size 6642 +oid sha256:f77dcecdfbad3614b383d2d8b276a04e8caa1f9491554f2755338217587a153d +size 6787 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png index 80c560d774..6d94d31305 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8081231a32a58a3434838b3ced72519748fee33f42ecbfa17088bbc45fa3707a -size 12646 +oid sha256:82cd589aa56ad9a6226fd4af36a4b48e6456f670531bdb5e803231f09d1ec07f +size 12634 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png index 3eb6f63292..9ca382da63 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b08efec9f2c25d1f5a83bc5aea8b64704918696dd5090c064a5b286dfdf10d6c -size 10622 +oid sha256:d2e9196acafb6ceffcc921687ef52537401b3614869f3e227fb3c2d4f92cfd66 +size 10609 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png index d1be5852a2..afc2670e76 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5dca764f7130788f5e0ab4fec70c82556fd69ac2367951846aaf398a959f4029 -size 38662 +oid sha256:f512394d2d3c4c145f94c44adacde25d1eca3071dbd9630b316fbdc741ff3bad +size 38634 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png index bf8ab560bd..5537926f15 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d9a428c2554b3868dfe47b1b319054026131f73ef18db121c99fb5ee8ed43a6 -size 28905 +oid sha256:dace47df09cf988bbfcde93fa17acda0ba3b2e9f2157d86b1173765cf23f12b8 +size 28832 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_NavigationBar_App_Bars_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_NavigationBar_App_Bars_en.png index df5fe2c644..b9f799d447 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_NavigationBar_App_Bars_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_NavigationBar_App_Bars_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a3192eaf372327180ae71b1d7b56daba4cba756ba7d506a570040019d8b352c -size 15374 +oid sha256:3058f85a590e0b9f9d4edcf780f0d566edd4e305094775126ff34224055cc58a +size 15262 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Day_0_en.png index b69ab5536f..d83e8b8709 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce7bf59a0466eb48442227ba712aeaaf0122d0fc4d538ce744e930667734ade -size 50733 +oid sha256:ca1e1fc458fffa15df5980f4e6c5f1d0eaadd5b47314d1357aaa50a85f7a5527 +size 45502 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Night_0_en.png index 0b54b9e972..9999adc528 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme_ColorAliases_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4dddc654176495c8cb28a0959d72ed94bc7e25fe83b1d42b9e35bc8a7025eecf -size 50563 +oid sha256:f6788b8099a1a98b3bc6ca70f73561cb8b7d34b67355e219e0f4bd831420e716 +size 44341 From 6cb8a855393f60448e600f0168284a8bed488584 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:43:49 +0200 Subject: [PATCH 33/47] Update maplibre.compose to v0.19.0 (#7884) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84028f6ce4..894444784c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ appyx = "1.7.1" sqldelight = "2.4.0" wysiwyg = "2.42.1" telephoto = "0.19.0" -maplibre-compose = "0.18.0" +maplibre-compose = "0.19.0" # Dependency analysis dependencyAnalysis = "3.19.2" From 118b5448ac6917b23ddce6ba3084d0cd6b2fc481 Mon Sep 17 00:00:00 2001 From: mxandreas Date: Fri, 9 Oct 2026 09:50:14 +0300 Subject: [PATCH 34/47] Revert to the SessionEnterpriseService approach --- enterprise | 2 +- .../configureroom/ConfigureRoomExtension.kt | 14 ------ .../configureroom/ConfigureRoomPresenter.kt | 11 +++-- .../NoopConfigureRoomExtension.kt | 16 ------- .../impl/ConfigureRoomPresenterTest.kt | 45 +++++++++++++++++-- .../api/SessionEnterpriseService.kt | 6 +++ .../impl/DefaultSessionEnterpriseService.kt | 1 + .../test/FakeSessionEnterpriseService.kt | 5 +++ .../root/NoopSecurityAndPrivacyExtension.kt | 16 ------- .../impl/root/SecurityAndPrivacyExtension.kt | 14 ------ .../impl/root/SecurityAndPrivacyPresenter.kt | 7 ++- .../impl/root/SecurityAndPrivacyState.kt | 6 +-- .../SecurityAndPrivacyStatePreviewParam.kt | 8 +++- .../root/SecurityAndPrivacyPresenterTest.kt | 45 ++++++++++++++++++- 14 files changed, 114 insertions(+), 82 deletions(-) delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt delete mode 100644 features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt delete mode 100644 features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt delete mode 100644 features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt diff --git a/enterprise b/enterprise index eb05f3b813..1a64cd5b86 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit eb05f3b8131dead9dceca483a1372d8bb4306ab1 +Subproject commit 1a64cd5b86b07b6be0be6a1d6515764743344013 diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt deleted file mode 100644 index 0b2f162e8e..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomExtension.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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.createroom.impl.configureroom - -/** Lets a build-specific implementation adjust what the configure room screen offers. */ -interface ConfigureRoomExtension { - /** Whether the public join rule may be offered. */ - suspend fun isPublicAccessAllowed(): Boolean -} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 57ae875771..86cd67546e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -76,7 +76,6 @@ class ConfigureRoomPresenter( private val roomAliasHelper: RoomAliasHelper, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val sessionEnterpriseService: SessionEnterpriseService, - private val configureRoomExtension: ConfigureRoomExtension, ) : Presenter { @AssistedFactory interface Factory { @@ -94,8 +93,8 @@ class ConfigureRoomPresenter( val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(initial = false) - val isPublicAccessAllowed by produceState(true) { - value = configureRoomExtension.isPublicAccessAllowed() + val arePublicRoomsDisabled by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() } val roomAddressValidity = remember { mutableStateOf(RoomAddressValidity.Unknown) @@ -150,13 +149,13 @@ class ConfigureRoomPresenter( // 1. If we are creating a space. // 2. If it has a parent space. // 3. If knocking is enabled. - // 4. If public access is allowed. + // 4. If the homeserver forbids public rooms. val parentSpace = createRoomConfig.parentSpace - val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, isPublicAccessAllowed) { + val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) { when { isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( - JoinRuleItem.PublicVisibility.Public.takeIf { isPublicAccessAllowed }, + JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.Private, ).toImmutableList() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt deleted file mode 100644 index 8735ffde33..0000000000 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/NoopConfigureRoomExtension.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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.createroom.impl.configureroom - -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.di.SessionScope - -@ContributesBinding(SessionScope::class) -class NoopConfigureRoomExtension : ConfigureRoomExtension { - override suspend fun isPublicAccessAllowed(): Boolean = true -} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index 5a247a78d8..ebd3cdcdc2 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -18,7 +18,6 @@ import io.element.android.features.createroom.impl.configureroom.ConfigureRoomSt import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore import io.element.android.features.createroom.impl.configureroom.JoinRuleItem -import io.element.android.features.createroom.impl.configureroom.NoopConfigureRoomExtension import io.element.android.features.createroom.impl.configureroom.RoomAddress import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState import io.element.android.features.enterprise.test.FakeSessionEnterpriseService @@ -58,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -523,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { @Test fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val createRoomLambda = lambdaRecorder> { Result.success(A_ROOM_ID) } val matrixClient = createMatrixClient().apply { createRoomResult = createRoomLambda @@ -545,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() { } } + @Test + fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest { + val presenter = createConfigureRoomPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private) + val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last() + assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest { + val presenter = createConfigureRoomPresenter( + isSpace = true, + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last() + assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { skipItems(1) return awaitItem() @@ -580,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), + sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, + ), ) = ConfigureRoomPresenter( isSpace = isSpace, initialParentSpaceId = initialParenSpaceId, @@ -596,6 +634,5 @@ class ConfigureRoomPresenterTest : RobolectricTest() { ), mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, sessionEnterpriseService = sessionEnterpriseService, - configureRoomExtension = NoopConfigureRoomExtension(), ) } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index 94f455f2ff..aff9a3ae5c 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -29,4 +29,10 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean + + /** + * Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one, + * nor to make an existing room or space public. + */ + suspend fun arePublicRoomsDisabledByHomeserver(): Boolean } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt index 3ac10d8a08..8a528257a6 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService { override suspend fun tweakMasUrl(url: String): String = url override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt index e198fca867..a461b87c95 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -16,6 +16,7 @@ class FakeSessionEnterpriseService( private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, + private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() }, ) : SessionEnterpriseService { override suspend fun init() { } @@ -31,4 +32,8 @@ class FakeSessionEnterpriseService( override suspend fun isEncryptionDisabledByHomeserver(): Boolean { return isEncryptionDisabledResult() } + + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean { + return arePublicRoomsDisabledResult() + } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt deleted file mode 100644 index 3471a48a90..0000000000 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/NoopSecurityAndPrivacyExtension.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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.securityandprivacy.impl.root - -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.di.RoomScope - -@ContributesBinding(RoomScope::class) -class NoopSecurityAndPrivacyExtension : SecurityAndPrivacyExtension { - override suspend fun isPublicAccessAllowed(): Boolean = true -} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt deleted file mode 100644 index 320d04a48d..0000000000 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyExtension.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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.securityandprivacy.impl.root - -/** Lets a build-specific implementation adjust what the security and privacy screen offers. */ -interface SecurityAndPrivacyExtension { - /** Whether the room access may be changed to anyone. */ - suspend fun isPublicAccessAllowed(): Boolean -} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index c79401ff1c..498e71dd0a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -65,7 +65,6 @@ class SecurityAndPrivacyPresenter( private val room: JoinedRoom, private val featureFlagService: FeatureFlagService, private val sessionEnterpriseService: SessionEnterpriseService, - private val securityAndPrivacyExtension: SecurityAndPrivacyExtension, ) : Presenter { @AssistedFactory interface Factory { @@ -90,8 +89,8 @@ class SecurityAndPrivacyPresenter( value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() } - val isPublicAccessAllowed by produceState(true) { - value = securityAndPrivacyExtension.isPublicAccessAllowed() + val arePublicRoomsDisabledByHomeserver by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() } val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } @@ -259,7 +258,7 @@ class SecurityAndPrivacyPresenter( selectableJoinedSpaces = selectableJoinedSpaces, spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, - isPublicAccessAllowed = isPublicAccessAllowed, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = ::handleEvent, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index d65493fda9..5b61953674 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -28,7 +28,7 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val isEncryptionDisabledByHomeserver: Boolean, - private val isPublicAccessAllowed: Boolean, + private val arePublicRoomsDisabledByHomeserver: Boolean, val showEnableEncryptionConfirmation: Boolean, private val isKnockEnabled: Boolean, val saveAction: AsyncAction, @@ -42,8 +42,8 @@ data class SecurityAndPrivacyState( // Show Anyone option when: // - Anyone is the current saved value, OR - // - Public access is allowed - val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || isPublicAccessAllowed + // - The homeserver allows public rooms + val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver // Show SpaceMember option in two cases: // - SpaceMember is the current saved value diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt index 44d13c4ca3..45383bf814 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt @@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider = emptySet(), spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, isEncryptionDisabledByHomeserver: Boolean = false, - isPublicAccessAllowed: Boolean = true, + arePublicRoomsDisabledByHomeserver: Boolean = false, eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -153,6 +157,6 @@ fun aSecurityAndPrivacyState( selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, - isPublicAccessAllowed = isPublicAccessAllowed, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 10669fc15a..5a0cf743e2 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest { @Test fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val presenter = createSecurityAndPrivacyPresenter( sessionEnterpriseService = sessionEnterpriseService, ) @@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest { } } + @Test + fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + consumeItemsUntilPredicate { !it.showAnyoneOption } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Public) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public) + ), + ), + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val states = consumeItemsUntilTimeout() + assertThat(states).isNotEmpty() + assertThat(states.all { it.showAnyoneOption }).isTrue() + } + } + private fun roomPermissions( canChangeRoomAccess: Boolean = true, canChangeHistoryVisibility: Boolean = true, @@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest { spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, ) ): SecurityAndPrivacyPresenter { return SecurityAndPrivacyPresenter( @@ -1109,7 +1151,6 @@ class SecurityAndPrivacyPresenterTest { featureFlagService = featureFlagService, spaceSelectionStateHolder = spaceSelectionStateHolder, sessionEnterpriseService = sessionEnterpriseService, - securityAndPrivacyExtension = NoopSecurityAndPrivacyExtension(), ) } } From 59bc387c7d41891afbf0f6008bb83ad088dc7c84 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Oct 2026 10:08:34 +0200 Subject: [PATCH 35/47] Replace the SearchBar by a SearchField in the room select screen The room select screen was using a SearchBar, with an active mode displaying the search results instead of the room list. Use a SearchField instead, displayed above the room list, which filters the rooms directly. The account switch section and the selected rooms are now also displayed while searching. - Remove isSearchActive from the state, and the ToggleSearchActive event. - The "No results" message is displayed when the query is not blank and no room matches it. - The back button now always closes the screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../roomselect/impl/RoomSelectEvent.kt | 1 - .../roomselect/impl/RoomSelectPresenter.kt | 5 +- .../roomselect/impl/RoomSelectState.kt | 1 - .../impl/RoomSelectStatePreviewParam.kt | 6 +- .../roomselect/impl/RoomSelectView.kt | 114 +++++++----------- .../impl/RoomSelectPresenterTest.kt | 15 --- .../roomselect/impl/RoomSelectViewTest.kt | 11 ++ 7 files changed, 59 insertions(+), 94 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt index 0f7baecf12..d08bce5026 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt @@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo sealed interface RoomSelectEvent { data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent - data object ToggleSearchActive : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent data object ToggleAccountListExpanded : RoomSelectEvent data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 3de9d22ba8..da2972aed2 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -63,7 +63,6 @@ class RoomSelectPresenter( override fun present(): RoomSelectState { var selectedRooms by remember { mutableStateOf(persistentListOf()) } val queryState = rememberTextFieldState() - var isSearchActive by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) } var selectedSessionId by remember { mutableStateOf(initialSessionId) } val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList()) @@ -136,7 +135,7 @@ class RoomSelectPresenter( derivedStateOf { when { roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) - isSearchActive -> SearchBarResultState.NoResultsFound + queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound else -> SearchBarResultState.Initial } } @@ -152,7 +151,6 @@ class RoomSelectPresenter( selectedRooms.adding(event.room) } } - RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded is RoomSelectEvent.SelectAccount -> { selectedSessionId = event.sessionId @@ -172,7 +170,6 @@ class RoomSelectPresenter( maxNumberOfRooms = maxNumberOfRooms, resultState = searchResults, searchQuery = queryState, - isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, hasRoomListError = hasRoomListError, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index 7af7b28ddf..7ec5fdf0a6 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -20,7 +20,6 @@ data class RoomSelectState( val maxNumberOfRooms: Int, val resultState: SearchBarResultState>, val searchQuery: TextFieldState, - val isSearchActive: Boolean, val selectedRooms: ImmutableList, val selectedAccount: MatrixUser, /** diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index 0c46c652ef..440179e55d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider get() = sequenceOf( aRoomSelectState(), - aRoomSelectState(searchQuery = "Test", isSearchActive = true), + aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound), aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState( resultState = SearchBarResultState.Results(aRoomSelectRoomList()), searchQuery = "Test", - isSearchActive = true, ), aRoomSelectState( resultState = SearchBarResultState.Results(aRoomSelectRoomList()), searchQuery = "Test", - isSearchActive = true, selectedRooms = aRoomSelectRoomList().subList(0, 1), ), aRoomSelectState( @@ -76,7 +74,6 @@ internal fun aRoomSelectState( maxNumberOfRooms: Int = 10, resultState: SearchBarResultState> = SearchBarResultState.Initial, searchQuery: String = "", - isSearchActive: Boolean = false, selectedRooms: ImmutableList = persistentListOf(), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), hasRoomListError: Boolean = false, @@ -88,7 +85,6 @@ internal fun aRoomSelectState( maxNumberOfRooms = maxNumberOfRooms, resultState = resultState, searchQuery = TextFieldState(initialText = searchQuery), - isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, hasRoomListError = hasRoomListError, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index a201faaa3d..96f3ac50b9 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -70,8 +71,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.SearchField import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -123,10 +124,8 @@ fun RoomSelectView( } var canHandleBack by remember { mutableStateOf(true) } - fun onBackButton(state: RoomSelectState) { - if (state.isSearchActive) { - state.eventSink(RoomSelectEvent.ToggleSearchActive) - } else if (canHandleBack) { + fun onBackButton() { + if (canHandleBack) { canHandleBack = false onDismiss() } @@ -134,7 +133,7 @@ fun RoomSelectView( BackHandler( enabled = canHandleBack, - onBack = { onBackButton(state) } + onBack = ::onBackButton, ) // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top @@ -159,7 +158,7 @@ fun RoomSelectView( navigationIcon = { BackButton( enabled = canHandleBack, - onClick = { onBackButton(state) } + onClick = ::onBackButton, ) }, actions = { @@ -179,81 +178,47 @@ fun RoomSelectView( .consumeWindowInsets(paddingValues) .then( // Only when the account switch section is displayed, else its unknown height would consume all the scroll events - if (state.showAccountSwitch && !state.isSearchActive) { + if (state.showAccountSwitch) { Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) } else { Modifier } ) ) { - SearchBar( + SearchField( + state = state.searchQuery, modifier = Modifier .fillMaxWidth() .padding( WindowInsets.safeDrawing .only(WindowInsetsSides.Horizontal) .asPaddingValues() - ), - placeHolderTitle = stringResource(CommonStrings.action_search), - queryState = state.searchQuery, - active = state.isSearchActive, - onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) }, - resultState = state.resultState, - showBackButton = false, - ) { summaries -> - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, - ) { - if (summaries.isNotEmpty()) { - item { - ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), - hasDivider = false, - ) - } - } - item { - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - showVerticalSpace = false, - ) - } - items(summaries, key = { it.roomId.value }) { roomSummary -> - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) - }, - canBeSelected = state.canSelectMoreRooms, - ) - } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = stringResource(CommonStrings.action_search), + ) + if (state.showAccountSwitch) { + // Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing + Spacer(modifier = Modifier.height(8.dp)) + TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { + AccountSwitchSection( + selectedAccount = state.selectedAccount, + otherAccounts = state.otherAccounts, + isExpanded = state.isAccountListExpanded, + onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, + onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, + ) } } - - if (!state.isSearchActive) { - if (state.showAccountSwitch) { - // Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing - Spacer(modifier = Modifier.height(8.dp)) - TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { - AccountSwitchSection( - selectedAccount = state.selectedAccount, - otherAccounts = state.otherAccounts, - isExpanded = state.isAccountListExpanded, - onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, - onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, - ) - } - } - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - // showVerticalSpace only if there is no other accounts - showVerticalSpace = !state.showAccountSwitch, - ) - if (state.hasRoomListError) { - RoomListError() - } else if (state.resultState is SearchBarResultState.Results) { + SelectedRoomsHelper( + selectedRooms = state.selectedRooms, + // showVerticalSpace only if there is no other accounts + showVerticalSpace = !state.showAccountSwitch, + ) + when { + state.hasRoomListError -> RoomListError() + state.resultState is SearchBarResultState.NoResultsFound -> NoResultsFound() + state.resultState is SearchBarResultState.Results -> { LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, @@ -278,11 +243,24 @@ fun RoomSelectView( } } } + else -> Unit } } } } +@Composable +private fun NoResultsFound() { + Text( + text = stringResource(CommonStrings.common_no_results), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 80.dp), + ) +} + @Composable private fun RoomListError() { IconTitleSubtitleMolecule( diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 0694cc27f0..50987f5ce6 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -59,7 +59,6 @@ class RoomSelectPresenterTest { val initialState = awaitItem() assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) - assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.canSelectMoreRooms).isTrue() assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) @@ -284,18 +283,6 @@ class RoomSelectPresenterTest { ), ) - @Test - fun `present - toggle search active`() = runTest { - val presenter = createRoomSelectPresenter() - presenter.test { - val initialState = awaitItem() - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isTrue() - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isFalse() - } - } - @Test fun `present - update query`() = runTest { val roomSummary = aRoomSummary() @@ -314,8 +301,6 @@ class RoomSelectPresenterTest { // Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result val result = (awaitItem().resultState as SearchBarResultState.Results).results assertThat(result).isEqualTo(listOf(expectedRoomInfo)) - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - skipItems(1) initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") assertThat( roomList.currentFilter.value diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index 71284e6ec5..3f60508816 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -155,6 +155,17 @@ class RoomSelectViewTest : RobolectricTest() { onNodeWithText("Room 0").assertIsNotDisplayed() } + @Test + fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + searchQuery = "Test", + resultState = SearchBarResultState.NoResultsFound, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed() + } + @Test fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { setRoomSelectView( From 0c6da39acb5cf99eee4d63e79a05e9c140446153 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Oct 2026 17:29:44 +0200 Subject: [PATCH 36/47] Room select: update empty search UI and behavior. --- .../roomselect/impl/RoomSelectPresenter.kt | 4 +- .../roomselect/impl/RoomSelectView.kt | 72 +++++++++++++++---- .../impl/RoomSelectPresenterTest.kt | 27 +++++++ .../src/main/res/values/localazy.xml | 2 + 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index da2972aed2..9229718f10 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -8,7 +8,6 @@ package io.element.android.libraries.roomselect.impl -import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -154,9 +153,8 @@ class RoomSelectPresenter( RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded is RoomSelectEvent.SelectAccount -> { selectedSessionId = event.sessionId - // The selected rooms and the search query are specific to the session + // The selected rooms are specific to the session. The search query is kept, and applied to the rooms of the new session. selectedRooms = persistentListOf() - queryState.clearText() isAccountListExpanded = false } is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 96f3ac50b9..023032bd43 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -10,12 +10,14 @@ package io.element.android.libraries.roomselect.impl import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -24,18 +26,23 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -46,6 +53,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontStyle @@ -136,13 +144,27 @@ fun RoomSelectView( onBack = ::onBackButton, ) - // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top - val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } + val searchQuery = state.searchQuery.text.toString() + // Use a new list state when the selected account or the search query changes, so that the list of rooms is scrolled to the top + val lazyListState = key(state.selectedAccount.userId, searchQuery) { rememberLazyListState() } // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. // Use a new state when the selected account changes, so that the section is fully displayed again. val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) } + // Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down. + val isSearching = searchQuery.isNotEmpty() + LaunchedEffect(isSearching) { + if (isSearching) { + val topAppBarState = accountSwitchScrollBehavior.state + animate( + initialValue = topAppBarState.heightOffset, + targetValue = topAppBarState.heightOffsetLimit, + ) { value, _ -> + topAppBarState.heightOffset = value + } + } + } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) } @@ -217,7 +239,7 @@ fun RoomSelectView( ) when { state.hasRoomListError -> RoomListError() - state.resultState is SearchBarResultState.NoResultsFound -> NoResultsFound() + state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery) state.resultState is SearchBarResultState.Results -> { LazyColumn( state = lazyListState, @@ -225,8 +247,13 @@ fun RoomSelectView( ) { if (state.resultState.results.isNotEmpty()) { item { + val headerText = if (searchQuery.isEmpty()) { + stringResource(CommonStrings.common_header_rooms) + } else { + stringResource(CommonStrings.common_results_for, searchQuery) + } ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), + title = headerText, hasDivider = false, ) } @@ -250,15 +277,36 @@ fun RoomSelectView( } @Composable -private fun NoResultsFound() { - Text( - text = stringResource(CommonStrings.common_no_results), - textAlign = TextAlign.Center, - color = ElementTheme.colors.textSecondary, +private fun NoResultsView( + searchQuery: String, +) { + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 80.dp), - ) + .fillMaxSize() + // Scrollable, so that the account switch section can be displayed again by scrolling down + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(8.dp), + ) { + Text( + text = stringResource(CommonStrings.common_no_results), + modifier = Modifier + .semantics { + heading() + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + ) + Text( + text = stringResource(CommonStrings.common_no_results_for, searchQuery), + modifier = Modifier.widthIn(max = 300.dp), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } } @Composable diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 50987f5ce6..2d04d28dc4 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -263,6 +263,33 @@ class RoomSelectPresenterTest { } } + @Test + fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest { + val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2)))) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { roomList2 }), + ) + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.searchQuery.setTextAndPlaceCursorAtEnd("query") + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val finalState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && it.resultState is SearchBarResultState.Results + }.last() + assertThat(finalState.searchQuery.text.toString()).isEqualTo("query") + assertThat(finalState.showAccountSwitch).isTrue() + assertThat(roomList2.currentFilter.value).isEqualTo(RoomListFilter.NormalizedMatchRoomName("query")) + } + } + @Test fun `present - forward mode with several accounts does not show the account switch`() = runTest { val presenter = createRoomSelectPresenter( diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 0eeb366d72..d2cf805c69 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -335,6 +335,7 @@ Reason: %1$s." "Mute" "Name" "%1$s (%2$s)" + "There are no results for “%1$s”. Try a new search term." "No results" "No room name" "No space name" @@ -381,6 +382,7 @@ Reason: %1$s." "Replying to %1$s" "Report a problem" "Report submitted" + "Results for “%1$s”" "Rich text editor" "Role" "Room" From 2177a62ff12c357f40dcab35ec661afc75859c54 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Oct 2026 17:42:29 +0200 Subject: [PATCH 37/47] Apply the room list filter requested before the controller is created The dynamic entries controller of a room list is created asynchronously, once the entries are observed. Filters set before that were silently dropped, so for instance the search query was not applied to the rooms of a newly selected account in the room select screen. RustDynamicRoomList now owns the controller, remembers the last requested filter, and applies it when the controller is created if it differs from the initial one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/roomlist/RoomListExtensions.kt | 2 +- .../matrix/impl/roomlist/RoomListFactory.kt | 19 +++--- .../impl/roomlist/RustDynamicRoomList.kt | 43 +++++++++--- ...FakeFfiRoomListDynamicEntriesController.kt | 25 +++++++ .../impl/roomlist/RustDynamicRoomListTest.kt | 67 +++++++++++++++++++ 5 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index bf57e4295b..301fb44bea 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow = internal fun RoomListInterface.entriesFlow( pageSize: Int, initialFilterKind: RoomListEntriesDynamicFilterKind, - onControllerCreated: (RoomListDynamicEntriesController) -> Unit, + onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit, ): Flow> = callbackFlow { val listener = object : RoomListEntriesListener { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt index 1d90b07104..c12ddbede8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListService import kotlin.coroutines.CoroutineContext @@ -49,7 +48,13 @@ internal class RoomListFactory( val summariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) var innerRoomList: InnerRoomList? = null - var dynamicController: RoomListDynamicEntriesController? = null + val dynamicRoomList = RustDynamicRoomList( + summaries = summariesFlow, + loadingState = loadingStateFlow, + processor = processor, + pageSize = pageSize, + initialFilter = initialFilter, + ) val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") @@ -60,7 +65,7 @@ internal class RoomListFactory( pageSize = pageSize, initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter), onControllerCreated = { controller -> - dynamicController = controller + dynamicRoomList.onControllerCreated(controller, initialFilter) } ).onEach { update -> if (!firstRoomsTransaction.isFinished()) { @@ -80,13 +85,7 @@ internal class RoomListFactory( }.invokeOnCompletion { innerRoomList?.destroy() } - return RustDynamicRoomList( - summaries = summariesFlow, - loadingState = loadingStateFlow, - processor = processor, - pageSize = pageSize, - dynamicController = { dynamicController } - ) + return dynamicRoomList } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt index 0d7b2394b3..5161b3d13a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt @@ -24,37 +24,58 @@ internal class RustDynamicRoomList( override val loadingState: MutableStateFlow, private val processor: RoomSummaryListProcessor, override val pageSize: Int, - private val dynamicController: () -> RoomListDynamicEntriesController?, + initialFilter: RoomListFilter, private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT ) : DynamicRoomList { private val mutex = Mutex() + // The controller is created asynchronously, once the entries of the room list are observed + private var dynamicController: RoomListDynamicEntriesController? = null + + // The last requested filter, so that it can be applied when the controller is created + private var currentFilter: RoomListFilter = initialFilter + + /** + * To be called when the controller is created. The controller already has the initial filter set, so apply the filter + * only if it has been updated in the meantime. + */ + suspend fun onControllerCreated(controller: RoomListDynamicEntriesController, initialFilter: RoomListFilter) { + mutex.withLock { + dynamicController = controller + if (currentFilter != initialFilter) { + controller.applyFilter(currentFilter) + } + } + } + override suspend fun rebuildSummaries() { processor.rebuildRoomSummaries() } override suspend fun updateFilter(filter: RoomListFilter) { mutex.withLock { - dynamicController()?.let { controller -> - // Reset pagination when filter changes - controller.resetToOnePage() - val rustFilter = RoomListFilterMapper.toRustFilter(filter) - controller.setFilter(rustFilter) - // Then preload some pages - controller.addPages(addPagesCount) - } + currentFilter = filter + dynamicController?.applyFilter(filter) } } + private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) { + // Reset pagination when filter changes + resetToOnePage() + setFilter(RoomListFilterMapper.toRustFilter(filter)) + // Then preload some pages + addPages(addPagesCount) + } + override suspend fun loadMore() { mutex.withLock { - dynamicController()?.addPages(addPagesCount) + dynamicController?.addPages(addPagesCount) } } override suspend fun reset() { mutex.withLock { - dynamicController()?.resetToOnePage() + dynamicController?.resetToOnePage() } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt new file mode 100644 index 0000000000..9a80841069 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt @@ -0,0 +1,25 @@ +/* + * 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.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind + +class FakeFfiRoomListDynamicEntriesController : RoomListDynamicEntriesController(NoHandle) { + val filters = mutableListOf() + + override fun setFilter(kind: RoomListEntriesDynamicFilterKind): Boolean { + filters.add(kind) + return true + } + + override fun resetToOnePage() = Unit + + override fun addOnePage() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt new file mode 100644 index 0000000000..fc2686f882 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt @@ -0,0 +1,67 @@ +/* + * 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.libraries.matrix.impl.roomlist + +import com.google.common.truth.Truth.assertThat +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.roomlist.RoomSummary +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListDynamicEntriesController +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RustDynamicRoomListTest { + @Test + fun `updateFilter before the controller is created applies the filter when it is created`() = runTest { + val sut = createRustDynamicRoomList() + sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query")) + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query"))) + } + + @Test + fun `when the filter is not updated, the controller filter is not changed when it is created`() = runTest { + val sut = createRustDynamicRoomList() + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + assertThat(controller.filters).isEmpty() + } + + @Test + fun `updateFilter after the controller is created applies the filter`() = runTest { + val sut = createRustDynamicRoomList() + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query")) + assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query"))) + } + + private fun TestScope.createRustDynamicRoomList(): RustDynamicRoomList { + val summaries = MutableSharedFlow>(replay = 1) + return RustDynamicRoomList( + summaries = summaries, + loadingState = MutableStateFlow(RoomList.LoadingState.NotLoaded), + processor = RoomSummaryListProcessor( + summaries, + FakeFfiRoomListService(), + coroutineContext = StandardTestDispatcher(testScheduler), + roomSummaryFactory = RoomSummaryFactory(), + analyticsService = FakeAnalyticsService(), + ), + pageSize = 10, + initialFilter = RoomListFilter.all(), + ) + } +} From 9429bd9420a5cba2f87e054d41e72a621341efda Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 09:04:41 +0200 Subject: [PATCH 38/47] Clean up --- .../android/libraries/roomselect/impl/RoomSelectView.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 023032bd43..4c5ad390e7 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -17,7 +17,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -287,7 +286,7 @@ private fun NoResultsView( .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 40.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(CommonStrings.common_no_results), From 2f7e2316e13f007bbf2cfed0b91bf66a4a81d7b0 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 9 Oct 2026 07:23:33 +0000 Subject: [PATCH 39/47] Update screenshots --- .../libraries.roomselect.impl_RoomSelectView_Day_0_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_1_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_5_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_6_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_7_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_8_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_9_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_0_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_1_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_5_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_6_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_7_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_8_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_9_en.png | 4 ++-- 20 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png index ed28276533..df5eb341fb 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf1e3ce457d2e28ee929ade5bfc1abfc0a62ef2dc8443824300d15f78e843b75 -size 11233 +oid sha256:9dad60d90d63d832243181f9eb3517a1ad15817bba3a0686e3c275a489d42921 +size 12363 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png index eacac1fc43..a4d55009a3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90452c32320a3eaad1c520885c487dc6242e1333f2084940c85e8f4060940b1e -size 9166 +oid sha256:27198b09382c662a0fd3b35ede7afa185ffea636f4fbfd236b00163169baa4c5 +size 21598 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png index 7ac7728536..f7c327daab 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96da17f60cf84b6e5052d9753e3e81fc6f27e1ca78bbadb4501c9e4c864b6eeb -size 34329 +oid sha256:83c813b6e07f8b22ade72dbec568417c7ed7b15aba661ecc0c59d67b9b962a26 +size 35361 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png index 48e6dbced5..06ae089b98 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da7a0e6c7ed39c6d1c8f900e32d6139da70333bac1356b017148f68ea7c1dcbf -size 32126 +oid sha256:fab8e57274220f8261e4650c227aeae92fc8994300285148a96ed844b0d7d8ea +size 36277 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png index 19362d4a71..679cea54de 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c234a4c6b0c3219e4cfc6330601b15f7dc1db385b4024c18bd7f3376c27f7122 -size 36112 +oid sha256:15a9aeba4a79ce52622bcb1761483603b415d34a14017390ecc481df59d6b640 +size 39922 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png index ce8da40ea2..b6628b455d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:512f9d0246ac666d024395672ef7ff33473a40d2fe261bc84b70c1bb4381a1bd -size 33822 +oid sha256:adb459b531ec73c8d0ec33ff7a1d55685f4829458588998c021b737743584d88 +size 34886 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png index 16a6ac408f..ed4d758af3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea0a573af47011a6f19ecc304d4cbb78522a1e6c41a353923425aeb7c8ea8e07 -size 37392 +oid sha256:66f4c2db94fa0e1230d46f4afb132a31053d6bbf66d876c7a2d71415c9d50a64 +size 38470 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png index d17e9a0c3d..ab906e63cf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06a3b2ffff122289becfa847e8ff4979987ba17aeab85e2c6d277f0d2234878a -size 44276 +oid sha256:a2dd9df048447b7cc742e81fa3e8436a4494b953132cb77e0614261c0e0ae3c8 +size 45326 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png index 718c7d031a..3e5a59a127 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:454810b015c969009fd610b5b0141a892f68234147b87724d5dad0a0d7b5aab3 -size 48658 +oid sha256:aafa2a411ed5dbc23948f70ac2745883ed5824a7148fc0e9344deac2d69fafca +size 49763 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png index 2625c34346..76829c2224 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e2d9e7c6b7b866b610df385ff5c7a61a5bb716efd4479f9a7f07e4c61e69487 -size 37335 +oid sha256:1013a91d529084a8f65776c2b097be3409d49344bf45c6f5634179e61d21aac4 +size 38419 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png index 149038c4f5..760d0f8033 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:71b4ecc3a18de7be9eb8ef453852f6f9a5c44ae8edb0a14cdf09a4a240c3faab -size 10703 +oid sha256:757573392dec052edf0ed522a344edca4777075ede316ae3fe757ec72ae47a03 +size 11757 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png index 9287ee04f3..7178aae2c0 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e4a4c20eb92cf93d5e7a76973b46863ed79b32e36c9d970b311b9681a382b23 -size 8957 +oid sha256:476aecd18345c3f0049a3cf455b3d0c6d9799774772cfc7044708a7eefdeec1c +size 20845 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png index c4d89d3fb0..20787d9daf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f472811552d7499bd0acd7c929224a643957816cb4122c1a95ceaf5a7b32f1f3 -size 34198 +oid sha256:210355754bec4be43cc9a183c2125a0baf1de282cd8d1e88e85919c326159e4c +size 35225 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png index 60b3f75e62..20273cb08a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d722f376b5cc87f8a11be2d42347fbf90f31b04699f9ec6373ed068454545372 -size 32336 +oid sha256:091fb942406728012bb8d95f17465166c92d2506b11703198fb75211865bf28d +size 36138 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png index c27956224c..bc19519280 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f45a3dff7b5b669c643394ba12e0df55344ff76177030d1ae1de57275c57c4c6 -size 36401 +oid sha256:207c51ecd3b0b39d20e4d54631aa2160120ba4711f121626f6ee56a00765b7fc +size 39859 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png index 0c2e69b595..62ebafd5df 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c402aa3102e2f31c8b9ec911fdfeddd626d65032973f3f5753b2117a90118edb -size 33636 +oid sha256:000e055f5b9cb2c002b4879977aee3d85bedf377794c74aa18f65527ff34ee43 +size 34657 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png index 8d1929baf4..75c8fef6bb 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54d3269f34abec26529050d7a89b581bdc3d5d1e20980d63993a08e73ec7a655 -size 37244 +oid sha256:03678c2b6caca9a63811717ba48d823924c855fa9b5b350eea6f6b942d3820d5 +size 38245 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png index 267a25e404..cd2b8e2a8f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e37a74fe450f9f150963f0099d25d51b5fca93905ebf24dd243e703344eec2d7 -size 44540 +oid sha256:152d862155867306be3bbede1b1734b6c76465bc0baabf77fd9b6af462ef99e8 +size 45521 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png index 075b7ea1ed..d93ab616e8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0cd1e2aba9e07a10237b310359f1011434ac4d55fe68acc79bf2f83461fbb88 -size 48843 +oid sha256:dae86fa884bb76b3f88a76d07894490e7779c1d73bc218de3f26f5e5c22b4680 +size 49785 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png index 9dc7466680..1374addcb3 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a327f4c2c4e3204a14a4ee601a1ddb784354a921525825868e0273f5c941f595 -size 36987 +oid sha256:389b46a2546a221927a0b9f632b10e347fd30fa088b7d26dd0f8a844b981e110 +size 37997 From b62999d4c08672d5ea8d46f2da2e06d21c1aae81 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 09:31:15 +0200 Subject: [PATCH 40/47] Draw the focus indicator around the whole SelectedItem Previously, only the small remove button could get the keyboard focus, and the focus ring was barely visible. Now the whole item is focusable, the focus ring is drawn around it, and pressing an activation key (Enter, Space, D-pad center) removes the item. Touch behavior is unchanged: only the remove button is clickable. Also add a shared `activationKeys` set in the designsystem module. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../designsystem/utils/ActivationKeys.kt | 20 ++++++++++++ .../matrix/ui/components/SelectedItem.kt | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt new file mode 100644 index 0000000000..60db43ecb3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt @@ -0,0 +1,20 @@ +/* + * 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.libraries.designsystem.utils + +import androidx.compose.ui.input.key.Key + +/** + * Keys which can be used to activate (i.e. click) the component having the keyboard focus. + */ +val activationKeys = setOf( + Key.Enter, + Key.NumPadEnter, + Key.DirectionCenter, + Key.Spacebar, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt index 29d2f469a0..36f2c674d8 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt @@ -9,6 +9,8 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,11 +27,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -48,6 +55,7 @@ import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.activationKeys import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -62,9 +70,31 @@ fun SelectedItem( modifier: Modifier = Modifier, ) { val actionRemove = stringResource(id = CommonStrings.action_remove) + val interactionSource = remember { MutableInteractionSource() } Box( modifier = modifier .width(avatarData.size.dp) + .then( + if (canRemove) { + // Let the whole item get the keyboard focus, so that the focus indicator is drawn around it. + Modifier + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key in activationKeys) { + onRemoveClick() + true + } else { + false + } + } + .indication( + interactionSource = interactionSource, + indication = ripple(), + ) + .focusable(interactionSource = interactionSource) + } else { + Modifier + } + ) .clearAndSetSemantics { contentDescription = a11yContentDescription if (canRemove) { @@ -130,6 +160,8 @@ fun SelectedItem( .clip(CircleShape) .size(20.dp) .align(Alignment.TopEnd) + // The whole item is focusable, not the remove button + .focusProperties { canFocus = false } .clickable( indication = ripple(), interactionSource = remember { MutableInteractionSource() }, From 1c6d9971f550c79b020961727a4b4e57b13adc91 Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 9 Oct 2026 09:36:09 +0200 Subject: [PATCH 41/47] Update element-call-android to 0.1.0-rc.7 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22bc56e42d..c1317a75ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,7 +64,7 @@ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" kover = "0.9.9" # The native MatrixRTC call component, https://github.com/element-hq/element-call-android -element_call = "0.1.0-rc.6" +element_call = "0.1.0-rc.7" [libraries] # Project From 76c1d9a4d238e77898f5e28e3210c127ca1c2a32 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 09:54:54 +0200 Subject: [PATCH 42/47] Add workflow to keep the enterprise submodule on the latest main When a PR is merged on develop with the enterprise submodule targeting a commit which is not on the enterprise main branch, open a PR to update it to the latest commit of main. The workflow also runs daily and manually to keep the submodule up to date. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../workflows/sync-enterprise-submodule.yml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/sync-enterprise-submodule.yml diff --git a/.github/workflows/sync-enterprise-submodule.yml b/.github/workflows/sync-enterprise-submodule.yml new file mode 100644 index 0000000000..ea843c01a0 --- /dev/null +++ b/.github/workflows/sync-enterprise-submodule.yml @@ -0,0 +1,87 @@ +name: Sync Enterprise submodule + +on: + workflow_dispatch: + push: + branches: [ develop ] + paths: [ enterprise ] + schedule: + # At 06:00 UTC every day + - cron: '0 6 * * *' + +permissions: {} + +jobs: + sync-enterprise-submodule: + runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'element-hq/element-x-android' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Add SSH private keys for submodule repositories + uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 + with: + ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} + - name: Check enterprise submodule reference + id: check + env: + EVENT_NAME: ${{ github.event_name }} + run: | + current=$(git ls-tree HEAD enterprise | awk '{print $3}') + # Do not init the submodule: the current commit may be on a deleted branch. + git init -q "$RUNNER_TEMP/enterprise" + git -C "$RUNNER_TEMP/enterprise" fetch -q git@github.com:element-hq/element-android-enterprise.git main + main=$(git -C "$RUNNER_TEMP/enterprise" rev-parse FETCH_HEAD) + if git -C "$RUNNER_TEMP/enterprise" merge-base --is-ancestor "$current" "$main" 2>/dev/null; then + on_main=true + else + on_main=false + fi + echo "Current: $current, main: $main, current is on main: $on_main" + + if [ "$current" = "$main" ]; then + echo "Submodule is already up to date." + echo "update=false" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "push" ] && [ "$on_main" = "true" ]; then + echo "Submodule targets a commit on main, nothing to do." + echo "update=false" >> "$GITHUB_OUTPUT" + else + echo "update=true" >> "$GITHUB_OUTPUT" + fi + + { + echo "body<> "$GITHUB_OUTPUT" + + if [ "$current" != "$main" ]; then + # Update the submodule reference without cloning it. + git update-index --cacheinfo "160000,$main,enterprise" + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -q -m "Update enterprise submodule to latest main" -m "Update to $main" + fi + - name: Create Pull Request to update the enterprise submodule + # On schedule and manual runs, also run when there is nothing to update, so that a stale PR gets closed. + if: ${{ steps.check.outputs.update == 'true' || github.event_name != 'push' }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + title: Update enterprise submodule to latest main + body: ${{ steps.check.outputs.body }} + branch: sync-enterprise-submodule + base: develop + delete-branch: true + labels: PR-Element-Pro From 90d5e3b617995012a8e7c6d99be6674b61dc55bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:30:21 +0000 Subject: [PATCH 43/47] Update enterprise submodule to latest main Update to 5c06c0f1e4fa12bf36f73cb8b4ea9018a4dece20 --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index 6c41677ca6..5c06c0f1e4 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 6c41677ca6477051ca0ae5e9aa5a3f150f42f3e8 +Subproject commit 5c06c0f1e4fa12bf36f73cb8b4ea9018a4dece20 From d8a072755f39599416469ae00a79936b4c30f328 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 9 Oct 2026 10:30:52 +0200 Subject: [PATCH 44/47] Add edit and redaction send state support (#7840) * Expose edit/redaction send states and retry/abort send from the Rust SDK Pending edits and redactions have their own send queue entries, separate from the original event. Surface them so the UI can reflect and act on them: - Add `SendTarget` (Event, Edit, Redaction, Reaction) to the matrix API, with a mapper to the Rust SDK type. - Add `editSendState` and `redactionSendState` to `EventTimelineItem` and map them from the SDK item. - Add `Timeline.retrySend` / `Timeline.abortSend`, which take a `SendTarget` so the right queued request is retried or removed. Implement them in `RustTimeline` and in `FakeTimeline`. * Track edit and redaction send states on timeline events `TimelineItem.Event` now carries `editSendState` and `redactionSendState` next to `localSendState`. - `failedToSend` is true when any of the three states is failed, so a failed edit or redaction is shown like a failed message. - `pendingSend()` returns the first pending state together with its `SendTarget`; the read receipt row, the send failure dialog and the timeline presenter use it instead of reading `localSendState` directly. - `TimelineItemReadReceiptView` is restructured so the row is only rendered when there is something to show, and the send state takes precedence over read receipts. - Update the previews provider and test fixtures for the new fields, and make `aMessageEvent`'s send state nullable. * Retry and remove failed sends through the timeline Retrying or removing a failed message used the event's `SendHandle`, which only knows about the original send. Route both through the timeline with the `SendTarget` of the pending request instead: - `TimelineController` gains `retrySend` (which also re-enables the send queue) and `abortSend`, acting on the current timeline. - `MessagesPresenter` retries via `retrySend`, and does nothing when the event has no pending send state. - Redacting something that was never sent (no event ID) or a pending edit/redaction now aborts the queued request rather than sending a redaction, and no confirmation is asked for. - Update the presenter tests accordingly. * Log send queue errors Log when the SDK reports a send queue error for a room, and when the send queue is disabled, to help diagnose messages stuck in the queue. * Make sure we display send queue errors before verification states * Fix logic for displaying read receipts and add more screenshot tests * Only use states with read receipts in `ReadReceiptBottomSheetStatePreviewParam` * Handle `LocalEventSendState = null` state just like `LocalEventSendState.Sent` * Add `TimelineItemAction.AbortSend` and use it for both: 1. Aborting sending a message/edit/redaction from the 'sending failed' dialog. 2. Abort sending a local message when selected from the timeline -> redact action. * Update screenshots --------- Co-authored-by: ElementBot --- .../android/appnav/loggedin/SendQueues.kt | 2 +- .../messages/impl/MessagesPresenter.kt | 17 ++-- .../features/messages/impl/MessagesView.kt | 2 +- .../actionlist/model/TimelineItemAction.kt | 3 +- .../model/TimelineItemActionComparator.kt | 3 +- .../impl/timeline/TimelineController.kt | 11 +++ .../impl/timeline/TimelinePresenter.kt | 4 +- .../impl/timeline/TimelineStateProvider.kt | 4 + .../components/TimelineEventTimestampView.kt | 15 ++-- .../components/TimelineItemEventRow.kt | 3 +- .../ReadReceiptViewStatePreviewParam.kt | 11 ++- .../receipt/TimelineItemReadReceiptView.kt | 83 +++++++++++-------- ...ReadReceiptBottomSheetStatePreviewParam.kt | 2 +- .../event/TimelineItemEventFactory.kt | 2 + .../impl/timeline/model/TimelineItem.kt | 17 +++- .../sendfailure/SendFailureDialogView.kt | 6 +- .../messages/impl/MessagesPresenterTest.kt | 43 +++++----- .../messages/impl/MessagesViewTest.kt | 2 +- .../impl/fixtures/MessageEventFixtures.kt | 4 +- .../groups/TimelineItemGrouperTest.kt | 2 + .../RedactedVoiceMessageManagerTest.kt | 2 + .../libraries/matrix/api/timeline/Timeline.kt | 17 ++++ .../matrix/api/timeline/item/SendTarget.kt | 28 +++++++ .../timeline/item/event/EventTimelineItem.kt | 2 + .../libraries/matrix/impl/RustMatrixClient.kt | 1 + .../matrix/impl/timeline/RustTimeline.kt | 13 +++ .../matrix/impl/timeline/SendTarget.kt | 17 ++++ .../item/event/EventTimelineItemMapper.kt | 8 +- .../matrix/test/timeline/FakeTimeline.kt | 13 ++- .../matrix/test/timeline/TimelineFixture.kt | 4 + ...msheet_ReadReceiptBottomSheet_Day_0_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_1_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_2_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_3_en.png | 4 +- ...msheet_ReadReceiptBottomSheet_Day_4_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_0_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_1_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_2_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_3_en.png | 4 +- ...heet_ReadReceiptBottomSheet_Night_4_en.png | 4 +- ..._TimelineItemReadReceiptView_Day_10_en.png | 3 + ...t_TimelineItemReadReceiptView_Day_8_en.png | 3 + ...t_TimelineItemReadReceiptView_Day_9_en.png | 3 + ...imelineItemReadReceiptView_Night_10_en.png | 3 + ...TimelineItemReadReceiptView_Night_8_en.png | 3 + ...TimelineItemReadReceiptView_Night_9_en.png | 3 + 46 files changed, 287 insertions(+), 112 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt index 3f01f8337e..177807d501 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt @@ -41,7 +41,7 @@ class SendQueues( fun launchIn(coroutineScope: CoroutineScope) { combine( syncService.syncState, - matrixClient.sendQueueDisabledFlow(), + matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") }, ) { syncState, _ -> syncState } .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS) .onEach { syncState -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 36d5dcaae9..5ebdd4ef19 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -86,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map @@ -441,14 +442,13 @@ class MessagesPresenter( TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) + TimelineItemAction.AbortSending -> handleActionAbortSend(targetEvent) } } private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) { - val sendHandle = targetEvent.sendhandle ?: return Unit.also { - Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}") - } - sendHandle.retry() + val (sendTarget, _) = targetEvent.pendingSend() ?: return + timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget) .onSuccess { Timber.d("Succeed to add the message back to the send queue") } @@ -538,13 +538,18 @@ class MessagesPresenter( private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { val eventId = event.eventId if (eventId == null) { - // The message was never sent, so there is nobody to give a reason to. - redact(event.eventOrTransactionId, reason = null) + // The message (or its edit/redaction) was never sent, just remove it from the send queue. + handleActionAbortSend(event) } else { redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) } } + private suspend fun handleActionAbortSend(event: TimelineItem.Event) { + val sendTarget = event.pendingSend()?.first ?: SendTarget.Event + timelineController.abortSend(event.eventOrTransactionId, sendTarget).onFailure { Timber.e(it) } + } + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 50b90be650..e5c312132d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -486,7 +486,7 @@ fun MessagesView( onRemoveMessage = { event -> state.eventSink( MessagesEvent.HandleAction( - action = TimelineItemAction.Redact, + action = TimelineItemAction.AbortSending, event = event, ) ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 3c64223e59..e78b2ac5e2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -37,6 +37,7 @@ enum class TimelineItemAction( Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), - // Note: this action is not used in UI for now + // Note: these actions are not used in UI for now RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid), + AbortSending(CommonStrings.action_cancel, CompoundDrawables.ic_compound_send_solid), } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 2f6fa4e4e0..3f0abf1b8d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -32,8 +32,9 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, - // Not rendered in the action list, but has to be listed here for exhaustiveness. + // Not rendered in the action list, but have to be listed here for exhaustiveness. TimelineItemAction.RetrySending, + TimelineItemAction.AbortSending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 05f7a06054..b2639d08c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.libraries.matrix.api.timeline.item.SendTarget +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.getAndUpdate @@ -134,6 +137,14 @@ class TimelineController( } } + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget) + } + + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget) + } + private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached -> when { detached.isPresent -> detached.get() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index a7006985a5..1888864311 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -302,7 +302,9 @@ class TimelinePresenter( } is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowSendFailureDialog -> { - val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return + val (_, sendStateFailure) = event.event.pendingSend() ?: return + if (sendStateFailure !is LocalEventSendState.Failed) return + when (sendStateFailure) { is LocalEventSendState.Failed.VerifiedUser -> { // defer to the resolveVerifiedUserSendFailureState to handle this case, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 7896c1fcad..5f9f56a910 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -159,6 +159,8 @@ internal fun aTimelineItemEvent( content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), @@ -185,6 +187,8 @@ internal fun aTimelineItemEvent( ), groupPosition = groupPosition, localSendState = sendState, + editSendState = editSendState, + redactionSendState = redactionSendState, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index d7492ca40b..7bc5cbf059 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -55,13 +55,20 @@ fun TimelineEventTimestampView( val shield = event.messageShield val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { + hasError -> stringResource(CommonStrings.a11y_view_details) shield != null -> stringResource(CommonStrings.a11y_view_details) isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) - hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } val clickableModifier = remember(event) { when { + hasError -> { + Modifier.clickable( + onClickLabel = onClickLabel, + ) { + eventSink(TimelineEvent.ShowSendFailureDialog(event)) + } + } shield != null -> { Modifier.clickable( onClickLabel = onClickLabel, @@ -69,12 +76,6 @@ fun TimelineEventTimestampView( eventSink(TimelineEvent.ShowShieldDialog(shield)) } } - hasError -> Modifier - .clickable( - onClickLabel = onClickLabel, - ) { - eventSink(TimelineEvent.ShowSendFailureDialog(event)) - } else -> Modifier } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 5d77a7ada3..2dfea52e4c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -324,9 +324,10 @@ fun TimelineItemEventRow( } // Read receipts / Send state + val sendState = event.pendingSend()?.second TimelineItemReadReceiptView( state = ReadReceiptViewState( - sendState = event.localSendState, + sendState = sendState, isLastOutgoingMessage = isLastOutgoingMessage, receipts = event.readReceiptState.receipts, ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt index 751a88cb39..0810a41dce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt @@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList class ReadReceiptViewStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aReadReceiptViewState(), - aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event), - aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), + aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true), aReadReceiptViewState( sendState = LocalEventSendState.Sent(EventId("\$eventId")), receipts = List(1) { aReadReceiptData(it) }, @@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider = emptyList(), ) = ReadReceiptViewState( sendState = sendState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 17a9b0c5eb..30b4ee2d42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -57,43 +57,60 @@ fun TimelineItemReadReceiptView( onReadReceiptsClick: () -> Unit, modifier: Modifier = Modifier, ) { - if (state.receipts.isNotEmpty()) { - ReadReceiptsRow(modifier = modifier) { - ReadReceiptsAvatars( - receipts = state.receipts, - onClick = onReadReceiptsClick, - ) + val content: @Composable (() -> Unit)? = when (state.sendState) { + // If null, assume it's Sent and act the same way + null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } } - } else { - when (state.sendState) { - is LocalEventSendState.Sending -> { - ReadReceiptsRow(modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.Circle(), - contentDescription = stringResource(id = CommonStrings.common_sending), - tint = ElementTheme.colors.iconSecondary - ) - } - } - is LocalEventSendState.Failed -> { - // Error? The timestamp is already displayed in red - } - null, - is LocalEventSendState.Sent -> { - if (state.isLastOutgoingMessage) { - ReadReceiptsRow(modifier = modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.CheckCircle(), - contentDescription = stringResource(id = CommonStrings.common_sent), - tint = ElementTheme.colors.iconSecondary - ) - } - } + is LocalEventSendState.Sending -> { + @Composable { SendingIcon() } + } + is LocalEventSendState.Failed -> { + // Error? The timestamp is already displayed in red + null + } + is LocalEventSendState.Sent if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } + } + else -> if (state.receipts.isNotEmpty()) { + @Composable { + ReadReceiptsAvatars( + receipts = state.receipts, + onClick = onReadReceiptsClick, + ) } + } else { + null } } + + // Only show the row if there is content to display + content?.let { + ReadReceiptsRow( + modifier = modifier, + content = it, + ) + } +} + +@Composable +private fun SendingIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.Circle(), + contentDescription = stringResource(id = CommonStrings.common_sending), + tint = ElementTheme.colors.iconSecondary + ) +} + +@Composable +private fun SentIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.CheckCircle(), + contentDescription = stringResource(id = CommonStrings.common_sent), + tint = ElementTheme.colors.iconSecondary + ) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt index 86c2beb707..9577ba297a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt @@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider = readReceiptViewStateProvider.values - .filter { it.sendState is LocalEventSendState.Sent } + .filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() } .map { readReceiptViewState -> ReadReceiptBottomSheetState( selectedEvent = aTimelineItemEvent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 6cd6b66cf1..b7b072880e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -123,6 +123,8 @@ class TimelineItemEventFactory( reactionsState = currentTimelineItem.computeReactionsState(), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts), localSendState = currentTimelineItem.event.localSendState, + editSendState = currentTimelineItem.event.editSendState, + redactionSendState = currentTimelineItem.event.redactionSendState, inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), threadInfo = mappedThreadInfo, origin = currentTimelineItem.event.origin, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 1cbc2ec29c..8a108b31f9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -92,6 +93,8 @@ sealed interface TimelineItem { val reactionsState: TimelineItemReactions, val readReceiptState: TimelineItemReadReceipts, val localSendState: LocalEventSendState?, + private val editSendState: LocalEventSendState?, + private val redactionSendState: LocalEventSendState?, val inReplyTo: InReplyToDetails?, val threadInfo: TimelineItemThreadInfo?, val origin: TimelineItemEventOrigin?, @@ -110,7 +113,7 @@ sealed interface TimelineItem { val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId) - val failedToSend: Boolean = localSendState is LocalEventSendState.Failed + val failedToSend: Boolean = listOf(localSendState, editSendState, redactionSendState).any { it is LocalEventSendState.Failed } val isTextMessage: Boolean = content is TimelineItemTextBasedContent @@ -143,6 +146,18 @@ sealed interface TimelineItem { get() = timelineItemDebugInfoProvider() val sendhandle: SendHandle? get() = sendHandleProvider() + + /** + * Returns the first pending send state, if any, along with the corresponding [SendTarget]. + */ + fun pendingSend(): Pair? { + return when { + localSendState != null -> SendTarget.Event to localSendState + editSendState != null -> SendTarget.Edit to editSendState + redactionSendState != null -> SendTarget.Redaction to redactionSendState + else -> null + } + } } data class GroupedEvents( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt index 15ee42d575..02f720bed2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -50,16 +50,18 @@ fun SendFailureDialogView( ) } + val (sendTarget, _) = sendFailureDialogState.event.pendingSend() ?: null to null + ConfirmationDialog( modifier = modifier, title = stringResource(id = CommonStrings.common_sending_failed), content = content, onDismiss = onDismiss, submitText = stringResource(id = CommonStrings.action_retry), - onSubmitClick = { onRetry(sendFailureDialogState.event) }, + onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } }, onCancelClick = onDismiss, thirdButtonText = stringResource(id = CommonStrings.action_remove_message), - onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) }, + onThirdButtonClick = { sendTarget?.let { onRemoveMessage(sendFailureDialogState.event) } }, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 17d2a2260d..3d3187af96 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -93,7 +93,6 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 -import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -646,24 +645,24 @@ class MessagesPresenterTest { } @Test - fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + fun `present - handle action redact - a message which was never sent is removed from the send queue without asking`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) - val liveTimeline = FakeTimeline() - val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } - liveTimeline.redactEventLambda = redactEventLambda + val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val presenter = createMessagesPresenter( - timeline = liveTimeline, + timeline = FakeTimeline(abortSendResult = abortSendLambda), coroutineDispatchers = coroutineDispatchers, ) presenter.testWithLifecycleOwner { val initialState = awaitItem() - val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + val localEcho = aMessageEvent( + eventId = null, + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) advanceUntilIdle() assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) - assert(redactEventLambda) - .isCalledOnce() - .with(value(localEcho.eventOrTransactionId), value(null)) + abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId)) } } @@ -1177,29 +1176,27 @@ class MessagesPresenterTest { @Test fun `present - handle action retry sending`() = runTest { - val retryLambda = lambdaRecorder> { Result.success(Unit) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - retryLambda.assertions().isCalledOnce() + retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId)) } } @Test fun `present - handle action retry sending - failure is ignored`() = runTest { - val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure(AN_EXCEPTION) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() @@ -1210,18 +1207,16 @@ class MessagesPresenterTest { } @Test - fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { - val messageEvent = aMessageEvent( - sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { null }, - ) - val presenter = createMessagesPresenter() + fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest { + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } + val messageEvent = aMessageEvent(sendState = null) + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - // No op! + retryLambda.assertions().isNeverCalled() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 677b2ac10b..e8613d3547 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() { timelineEventsRecorder.clear() clickOn(CommonStrings.action_remove_message) - messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem)) timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 1b9394f2ee..87dbd2119b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -43,7 +43,7 @@ internal fun aMessageEvent( content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, - sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), + sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID), debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } @@ -62,6 +62,8 @@ internal fun aMessageEvent( reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = sendState, + editSendState = null, + redactionSendState = null, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index f6ef4f0fc2..b9bb13649a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -43,6 +43,8 @@ class TimelineItemGrouperTest { reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + editSendState = null, + redactionSendState = null, isEditable = false, canBeRepliedTo = false, inReplyTo = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index 9a5eda366b..ae832ade45 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( isOwn = false, isRemote = false, localSendState = null, + editSendState = null, + redactionSendState = null, reactions = persistentListOf(), receipts = persistentListOf(), sender = A_USER_ID, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 51a8964aa0..d26d8cc00c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -423,4 +424,20 @@ interface Timeline : AutoCloseable { * Get the latest event id of the timeline, or `null` when it holds no event yet. */ suspend fun getLatestEventId(): Result + + /** + * Retries sending an event that failed to send, by putting it back in the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to retry sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result + + /** + * Aborts sending an event that failed to send, by removing it from the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to abort sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt new file mode 100644 index 0000000000..b882636e8e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt @@ -0,0 +1,28 @@ +/* + * 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.libraries.matrix.api.timeline.item + +import androidx.compose.runtime.Immutable + +/** + * Represents the target action of a send operation in a timeline. + */ +@Immutable +sealed interface SendTarget { + /** Send a new event. */ + data object Event : SendTarget + + /** Edit an existing event. */ + data object Edit : SendTarget + + /** Redact an existing event. */ + data object Redaction : SendTarget + + /** React to an existing event with the given key. */ + data class Reaction(val key: String) : SendTarget +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 8a3221677d..c2c110dd5f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -24,6 +24,8 @@ data class EventTimelineItem( val isOwn: Boolean, val isRemote: Boolean, val localSendState: LocalEventSendState?, + val editSendState: LocalEventSendState?, + val redactionSendState: LocalEventSendState?, val reactions: ImmutableList, val receipts: ImmutableList, val sender: UserId, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 70dbff1209..a9eb22cc8b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -877,6 +877,7 @@ class RustMatrixClient( override fun sendQueueDisabledFlow(): Flow = mxCallbackFlow { innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener { override fun onError(roomId: String, error: ClientException) { + Timber.e(error, "Send queue error for room $roomId") trySend(RoomId(roomId)) } }) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index c7dd260d47..d875ac0429 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl @@ -697,6 +698,18 @@ class RustTimeline( } } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + private suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(dispatcher) { runCatchingExceptions { inner.fetchDetailsForEvent(eventId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt new file mode 100644 index 0000000000..889735cad9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt @@ -0,0 +1,17 @@ +/* + * 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.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.item.SendTarget + +fun SendTarget.map() = when (this) { + is SendTarget.Event -> org.matrix.rustcomponents.sdk.SendTarget.Event + is SendTarget.Edit -> org.matrix.rustcomponents.sdk.SendTarget.Edit + is SendTarget.Redaction -> org.matrix.rustcomponents.sdk.SendTarget.Redaction + is SendTarget.Reaction -> org.matrix.rustcomponents.sdk.SendTarget.Reaction(key) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index f77e690ebf..3225dffc1c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -51,6 +51,8 @@ class EventTimelineItemMapper( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState?.map(), + editSendState = editSendState?.map(), + redactionSendState = redactionSendState?.map(), reactions = reactions.map(), receipts = readReceipts.map(), sender = UserId(sender), @@ -113,11 +115,7 @@ fun RustEventSendState?.map(): LocalEventSendState? { ) } is QueueWedgeError.GenericApiError -> { - if (isRecoverable) { - LocalEventSendState.Sending.Event - } else { - LocalEventSendState.Failed.Unknown(queueWedgeError.msg) - } + LocalEventSendState.Failed.Unknown(queueWedgeError.msg) } is QueueWedgeError.InvalidMimeType -> { LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 88dcb13a8c..07c4825cb9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler @@ -60,7 +61,9 @@ class FakeTimeline( receiptType: ReceiptType, ) -> Result = { _, _ -> lambdaError() - } + }, + private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, + private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, ) : Timeline { var sendMessageLambda: ( body: String, @@ -477,6 +480,14 @@ class FakeTimeline( return getLatestEventIdResult() } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return retrySendResult(eventOrTransactionId) + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return abortSendResult(eventOrTransactionId) + } + var closeCounter = 0 private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index da8b872ef0..c1e156caf6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -52,6 +52,8 @@ fun anEventTimelineItem( isOwn: Boolean = false, isRemote: Boolean = false, localSendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, reactions: ImmutableList = persistentListOf(), receipts: ImmutableList = persistentListOf(), sender: UserId = A_USER_ID, @@ -69,6 +71,8 @@ fun anEventTimelineItem( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState, + editSendState = editSendState, + redactionSendState = redactionSendState, reactions = reactions, receipts = receipts, sender = sender, diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png index c3e5011041..4dcdb1b9ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cd23b8ab206543d9632589c9c6e3ae157846cbbeef683d187f0fb8a7475b27d -size 6698 +oid sha256:b161e07f504adbb140c80945cf9cd5b31dad4edeeb86d1b683fef4f02148a110 +size 12178 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png index 4dcdb1b9ae..63b599fe61 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b161e07f504adbb140c80945cf9cd5b31dad4edeeb86d1b683fef4f02148a110 -size 12178 +oid sha256:df393203d4535dd63962cae667253c00c19dc4b6e25c887619ff5e833066f4ba +size 17596 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png index 63b599fe61..584992b643 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df393203d4535dd63962cae667253c00c19dc4b6e25c887619ff5e833066f4ba -size 17596 +oid sha256:6f4886f895d90cbd175fab866329841b45a13890b923ee7928673698d320b60f +size 22940 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png index 584992b643..659db847cc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f4886f895d90cbd175fab866329841b45a13890b923ee7928673698d320b60f -size 22940 +oid sha256:541e50bba5a4dd70015bbc5410d8d8c9b0cd194ab119918f64baf73cefdd618d +size 27956 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png index 659db847cc..3fe4168506 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:541e50bba5a4dd70015bbc5410d8d8c9b0cd194ab119918f64baf73cefdd618d -size 27956 +oid sha256:2023632334cfbd15b399fce33cb5ac04135f371d6cb09669fd66f97bc86e8fae +size 32655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png index 15ae66d08f..584bac4e93 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7580ba96fcc879011f591ac4bacd16d3c7cdc7db51f39cc7989c7c4dffdaa69 -size 5937 +oid sha256:eaf905401acff2b0000abc9c7a13b8034c55d12e355908dded5b1a24fe573fb8 +size 11366 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png index 584bac4e93..43aaf422f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eaf905401acff2b0000abc9c7a13b8034c55d12e355908dded5b1a24fe573fb8 -size 11366 +oid sha256:e6dc3ec6b43eda48a6d96af60aa983c2a8c42d4da81ef48dc2bd783309de8316 +size 16469 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png index 43aaf422f2..6382583d2d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6dc3ec6b43eda48a6d96af60aa983c2a8c42d4da81ef48dc2bd783309de8316 -size 16469 +oid sha256:fa666b0e8ec6d77820c31ebeed2ba9fc9868d07e56eb77ff079b5cd2b2840224 +size 21836 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png index 6382583d2d..e9321c5510 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa666b0e8ec6d77820c31ebeed2ba9fc9868d07e56eb77ff079b5cd2b2840224 -size 21836 +oid sha256:e260c421c187424b8d139e75351aa3e654fcf28975aebc4bbcefd8f77c2d21d6 +size 27082 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png index e9321c5510..db2b180cfd 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e260c421c187424b8d139e75351aa3e654fcf28975aebc4bbcefd8f77c2d21d6 -size 27082 +oid sha256:48537a0e326d7cf1fc36060fd2831bfa3b237806a79b492368471de25f331a58 +size 32174 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png new file mode 100644 index 0000000000..c7b23e6596 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49d4c593002d2ed5f6ace886d9220f3d236356cf1cefd8f7c0da68177032d0bc +size 4561 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png new file mode 100644 index 0000000000..ac2beb8344 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bc4fe868c3386c0ff38ee807ccfd098943bdb08595372378cffc0df44956b2c +size 4448 From dfccc42326e3416a67856515a88ca2cfb21b2362 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 10:33:22 +0200 Subject: [PATCH 45/47] Do not list enterprise commit messages in the submodule update PR The enterprise repository is private, so only mention the number of new commits in the generated PR description. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/sync-enterprise-submodule.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-enterprise-submodule.yml b/.github/workflows/sync-enterprise-submodule.yml index ea843c01a0..d7bb28d776 100644 --- a/.github/workflows/sync-enterprise-submodule.yml +++ b/.github/workflows/sync-enterprise-submodule.yml @@ -54,11 +54,9 @@ jobs: { echo "body< Date: Fri, 9 Oct 2026 14:28:17 +0200 Subject: [PATCH 46/47] Release room list entries resources even if onControllerCreated is cancelled Use try/finally instead of the awaitClose block, so that the entries stream, the controller and the dynamic adapters are destroyed even if the suspending onControllerCreated callback is cancelled or throws before awaitClose is reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../matrix/impl/roomlist/RoomListExtensions.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index 301fb44bea..2352f82c3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow( listener = listener, ) val controller = result.controller() - controller.setFilter(initialFilterKind) - onControllerCreated(controller) - awaitClose { + // Use try/finally rather than awaitClose's block, so that the resources are released even if + // onControllerCreated is cancelled or throws before awaitClose is reached + try { + controller.setFilter(initialFilterKind) + onControllerCreated(controller) + awaitClose() + } finally { result.entriesStream().cancelAndDestroy() controller.destroy() result.destroy() From 85224988af726eddf854b014ff457bf92fb79560 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 14:29:28 +0200 Subject: [PATCH 47/47] Room select: address review remarks - Scroll the list to the top using a LaunchedEffect instead of recreating the LazyListState on each search query change. - Use isBlank to check the search query, to match the logic of RoomSelectSearchDataSource. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../libraries/roomselect/impl/RoomSelectView.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 4c5ad390e7..dcae5b9f87 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -144,15 +144,19 @@ fun RoomSelectView( ) val searchQuery = state.searchQuery.text.toString() - // Use a new list state when the selected account or the search query changes, so that the list of rooms is scrolled to the top - val lazyListState = key(state.selectedAccount.userId, searchQuery) { rememberLazyListState() } + // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top + val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } + // Scroll the list of rooms to the top when the search query changes + LaunchedEffect(lazyListState, searchQuery) { + lazyListState.scrollToItem(0) + } // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. // Use a new state when the selected account changes, so that the section is fully displayed again. val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) } // Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down. - val isSearching = searchQuery.isNotEmpty() + val isSearching = searchQuery.isNotBlank() LaunchedEffect(isSearching) { if (isSearching) { val topAppBarState = accountSwitchScrollBehavior.state @@ -246,7 +250,7 @@ fun RoomSelectView( ) { if (state.resultState.results.isNotEmpty()) { item { - val headerText = if (searchQuery.isEmpty()) { + val headerText = if (searchQuery.isBlank()) { stringResource(CommonStrings.common_header_rooms) } else { stringResource(CommonStrings.common_results_for, searchQuery)