diff --git a/.github/workflows/sync-enterprise-submodule.yml b/.github/workflows/sync-enterprise-submodule.yml new file mode 100644 index 0000000000..d7bb28d776 --- /dev/null +++ b/.github/workflows/sync-enterprise-submodule.yml @@ -0,0 +1,85 @@ +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 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/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/enterprise b/enterprise index 819746a734..5c06c0f1e4 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 819746a734fbbeb4ffcbf3e842f7cc694cccc8a4 +Subproject commit 5c06c0f1e4fa12bf36f73cb8b4ea9018a4dece20 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/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 669c414045..6d3bc60414 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 @@ -54,13 +54,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 5774f99d0a..651b852a57 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 @@ -47,7 +47,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( @@ -59,7 +59,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/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/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 38aa5b75ce..7a209ad445 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 @@ -23,6 +23,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/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 40d62313ec..503d30c6d9 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 @@ -181,7 +181,7 @@ class MessagesFlowNode( @Parcelize data class ForwardEvent( - val eventId: EventId, + val eventIds: List, @IgnoredOnParcel val timelineProvider: TimelineProvider? = null, ) : NavTarget @@ -306,8 +306,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { @@ -464,7 +464,7 @@ class MessagesFlowNode( is NavTarget.ForwardEvent -> { // If no timeline provider is received, assume the live timeline should be used val timelineProvider = navTarget.timelineProvider ?: 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, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider)) + backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider)) } override fun navigateToThread(threadRootId: ThreadId) { @@ -617,8 +617,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) + override fun handleForwardEventClick(eventIds: List, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider)) } 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 424d8bcd21..de7d650a15 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 @@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList interface MessagesNavigator { fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) + fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) 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 62830ef1ba..4ed612614b 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 @@ -138,7 +138,7 @@ class MessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) + fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -221,8 +221,8 @@ class MessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - callback.forwardEvent(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + callback.forwardEvents(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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 89556bb1e1..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 @@ -38,6 +38,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 @@ -52,6 +54,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 @@ -83,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 @@ -257,6 +261,17 @@ class MessagesPresenter( redactEventAction = redactEventAction, ) } + is MessagesEvent.ConfirmSelectionAction -> { + val selection = timelineState.selectionState as? SelectionState.Active + if (selection != null) { + when (selection.action) { + SelectionAction.Forward -> { + navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController) + } + } + timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + } is MessagesEvent.ConfirmRedact -> { val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction redactEventAction.value = AsyncAction.Uninitialized @@ -413,21 +428,27 @@ 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), timelineController) + } + } 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.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") } @@ -517,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) @@ -612,11 +638,6 @@ class MessagesPresenter( navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) } - private fun handleForwardAction(event: TimelineItem.Event) { - if (event.eventId == null) return - navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController) - } - 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 f3ece96c2e..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 @@ -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 @@ -77,24 +78,31 @@ 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 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 @@ -134,6 +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.toImmutableSet import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -178,6 +187,18 @@ 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() + } + } + + BackHandler(enabled = state.timelineState.isSelectionModeActive) { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId @@ -234,34 +255,50 @@ 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 -> { + 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 -> { + 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 -> @@ -449,7 +486,7 @@ fun MessagesView( onRemoveMessage = { event -> state.eventSink( MessagesEvent.HandleAction( - action = TimelineItemAction.Redact, + action = TimelineItemAction.AbortSending, event = event, ) ) @@ -728,6 +765,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 } + .toImmutableSet() + 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 { 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/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 0a8b23d845..9783c679d3 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 @@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel 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 @@ -289,6 +290,7 @@ private fun PinnedMessagesListLoaded( onContentLayoutChange = onContentLayoutChange ) }, + selectionData = TimelineItemSelectionData(), ) } } 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 34f2b1fc74..851438ae5c 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 @@ -143,7 +143,7 @@ class ThreadedMessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) + fun handleForwardEventClick(eventIds: List, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -227,8 +227,8 @@ class ThreadedMessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - callback.handleForwardEventClick(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + callback.handleForwardEventClick(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt new file mode 100644 index 0000000000..3c44839468 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -0,0 +1,88 @@ +/* + * 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 + +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 +import kotlinx.collections.immutable.toImmutableSet + +/** Maximum number of messages that can be selected at once. */ +const val MAX_SELECTION_COUNT = 10 + +@Immutable +sealed interface SelectionState { + /** Selection mode is not active. */ + data object Disabled : SelectionState + + /** + * 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, + ) : 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() +} + +/** + * 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).toImmutableSet()) +} + +/** + * 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/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/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 82f13a6dae..fdd1efa620 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 @@ -28,7 +28,9 @@ sealed interface TimelineEvent { data object HideShieldDialog : TimelineEvent data object MarkAllAsRead : TimelineEvent - + data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent + data class ToggleSelection(val eventId: EventId) : TimelineEvent + data object ExitSelectionMode : TimelineEvent data class CopyToClipboard(val text: String) : 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 516abcdfd8..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 @@ -48,6 +48,8 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.toast.ToastHelper 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 @@ -71,13 +73,17 @@ 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.toImmutableSet 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 @@ -103,6 +109,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, @@ -172,6 +179,18 @@ class TimelinePresenter( value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer) } + 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) { @@ -252,6 +271,26 @@ class TimelinePresenter( is TimelineEvent.JumpToLive -> { timelineController.focusOnLive() } + is TimelineEvent.EnterSelectionMode -> { + if (!isMultiSelectEnabled) return + selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId)) + } + is TimelineEvent.ToggleSelection -> { + val current = selectionState as? SelectionState.Active ?: return + val selected = current.selectedEventIds + selectionState = when { + // 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 + } + else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet()) + } + } + is TimelineEvent.ExitSelectionMode -> { + selectionState = SelectionState.Disabled + } TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { @@ -263,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, @@ -341,6 +382,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, @@ -477,6 +526,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, + selectionState = selectionState, useNewTimelineEventRenderer = useNewTimelineEventRenderer, 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 9376747715..dcbbc5e13d 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 @@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.UserId 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( @@ -38,6 +40,7 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, + val selectionState: SelectionState, // When true, formatted message bodies are rendered with the native Compose renderer. val useNewTimelineEventRenderer: Boolean, val eventSink: (TimelineEvent) -> Unit, @@ -104,3 +107,43 @@ 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(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/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index af5bca7b2d..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 @@ -63,6 +63,7 @@ fun aTimelineState( displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, + selectionState: SelectionState = SelectionState.Disabled, useNewTimelineEventRenderer: Boolean = false, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { @@ -85,6 +86,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, + selectionState = selectionState, useNewTimelineEventRenderer = useNewTimelineEventRenderer, eventSink = eventSink, ) @@ -157,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(), @@ -183,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/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0dbe6c315f..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 @@ -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 @@ -88,8 +89,10 @@ 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.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 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.atomic.atoms.UnreadIndicatorAtom @@ -108,6 +111,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 @@ -181,6 +185,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()) { val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) { @@ -213,12 +229,22 @@ fun TimelineView( isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, displayThreadSummaries = state.displayThreadSummaries, + selectionData = TimelineItemSelectionData( + isSelectionModeActive = isSelectionModeActive, + progress = selectionProgress, + isSelected = state.isSelected(timelineItem), + canBeSelected = state.canSelect(timelineItem), + ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, - onContentClick = onContentClick, + onContentClick = { event -> + if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event) + }, onGalleryItemClick = onGalleryItemClick, - onLongClick = onMessageLongClick, + onLongClick = { event -> + if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event) + }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -571,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, ) @@ -617,6 +642,36 @@ internal fun TimelineViewPreview( } } +@PreviewsDayNight +@Composable +internal fun TimelineViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventId = timelineItems.filterIsInstance().first { SelectionAction.Forward.canApplyTo(it) }.eventId!! + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(SelectionAction.Forward, 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/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/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..f405b79e69 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -0,0 +1,48 @@ +/* + * 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 + +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 io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Checkbox + +/** + * 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, +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Checkbox( + modifier = modifier, + checked = selected, + onCheckedChange = null, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SelectionIndicatorPreview() = ElementPreview { + SelectionIndicator(selected = false) +} + +@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/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 97ec71173a..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 @@ -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 } @@ -319,14 +324,18 @@ 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, ), 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 0128e80b79..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 @@ -190,6 +191,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 aa66c262b5..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 @@ -8,28 +8,39 @@ package io.element.android.features.messages.impl.timeline.components +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.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.Role 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 import io.element.android.compound.theme.ElementTheme 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 @@ -60,6 +71,7 @@ internal fun TimelineItemRow( timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, displayThreadSummaries: Boolean, + selectionData: TimelineItemSelectionData, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -92,17 +104,148 @@ 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 { + timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> { + 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 { 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. + 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 + 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) { + // In selection mode the whole row toggles selection. + Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { + if (selectableEvent != null) { + 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. */ +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, + modifier: Modifier = Modifier, + selectionContentOffset: Dp = 0.dp, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, +) { + Box(modifier) { when (timelineItem) { is TimelineItem.Virtual -> { TimelineItemVirtualRow( @@ -197,6 +340,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/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/model/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt new file mode 100644 index 0000000000..be2c39c6bf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.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.features.messages.impl.timeline.model + +/** + * 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]. + * @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 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. + */ +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/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/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..5cd2b15bda --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -0,0 +1,78 @@ +/* + * 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.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.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 +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) +@Composable +fun SelectionModeTopBar( + selectedCount: Int, + action: SelectionAction, + onCancelClick: () -> Unit, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + title = { + Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount)) + }, + navigationIcon = { + IconButton( + onClick = onCancelClick, + ) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) + } + }, + actions = { + IconButton( + onClick = onConfirmClick, + enabled = selectedCount > 0, + ) { + Icon( + resourceId = action.iconRes, + contentDescription = stringResource(action.titleRes), + ) + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectionModeTopBarPreview( + @PreviewParameter(SelectionCountPreviewParam::class) count: Int, +) = ElementPreview { + SelectionModeTopBar( + selectedCount = count, + action = SelectionAction.Forward, + onCancelClick = {}, + onConfirmClick = {}, + ) +} + +internal class SelectionCountPreviewParam : PreviewParameterProvider { + override val values = sequenceOf(0, 1, 5) +} 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 31f6e4604e..c8ce530e27 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 @@ -20,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList class FakeMessagesNavigator( private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, - private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() }, + private val onForwardEventClickLambda: (eventIds: List, timelineProvider: TimelineProvider) -> 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() }, @@ -35,8 +35,8 @@ class FakeMessagesNavigator( onShowEventDebugInfoClickLambda(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - onForwardEventClickLambda(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + onForwardEventClickLambda(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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 858a1f345f..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 @@ -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 @@ -78,6 +82,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_REASON @@ -88,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 @@ -118,6 +122,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 @@ -289,17 +294,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), any()) + 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, TimelineProvider, 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)), any()) } } @@ -620,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)) } } @@ -1151,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() @@ -1184,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() } } @@ -1501,6 +1522,49 @@ class MessagesPresenterTest { canPinUnpin = canPinUnpin, ) + @Test + fun `present - confirm selection action forwards the selection in timeline order`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, TimelineProvider, 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)), any()) + 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, TimelineProvider, 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(), @@ -1523,6 +1587,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), timelineEventSink: (TimelineEvent) -> Unit = {}, + timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink), permalinkParser: PermalinkParser = FakePermalinkParser(), messageComposerPresenter: Presenter = Presenter { aMessageComposerState( @@ -1545,7 +1610,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/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/SelectionActionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt new file mode 100644 index 0000000000..1d7155f742 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt @@ -0,0 +1,60 @@ +/* + * 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 + +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/SelectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt new file mode 100644 index 0000000000..920b3bdaae --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt @@ -0,0 +1,89 @@ +/* + * 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 + +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.toImmutableSet +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.toImmutableSet(), + ) +} 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 f09e595e7c..5f161bfea0 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 @@ -38,6 +38,7 @@ import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.toast.FakeToastHelper import io.element.android.libraries.androidutils.toast.ToastHelper 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 @@ -91,6 +92,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 @@ -1861,6 +1863,152 @@ 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(SelectionAction.Forward, AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, 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(SelectionAction.Forward, AN_EVENT_ID)) + expectNoEvents() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + 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()))) + ) + 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.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() + } + } + + @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(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + 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()))) + ) + 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 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() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } @@ -1958,6 +2106,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/TimelineStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt new file mode 100644 index 0000000000..8e3cb997c2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt @@ -0,0 +1,77 @@ +/* + * 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 + +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/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/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( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84028f6ce4..965aee55c0 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" @@ -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 @@ -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" @@ -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" @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 5097068a8d..9fa7f28828 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1eca32e7b4..a78c88e539 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 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/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/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 d241d72c68..406bf7ddb3 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 @@ -154,6 +154,14 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + MessageMultiSelect( + key = "feature.message_multi_select", + title = "Multi-select messages", + description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.", + defaultValue = { false }, + isFinished = false, + isInLabs = false, + ), NewTimelineEventRenderer( key = "feature.new_timeline_event_renderer", title = "New timeline event renderer", 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..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 @@ -316,12 +317,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. @@ -422,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/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/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index bf57e4295b..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 @@ -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 { @@ -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() 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/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..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 @@ -489,9 +490,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) } @@ -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/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(), + ) + } +} 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..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, @@ -355,10 +358,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: ( @@ -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/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() }, 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/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 ff2036226d..6a9f367e0b 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 @@ -67,7 +66,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) } // Null until the sessions are loaded val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null) @@ -157,7 +155,7 @@ class RoomSelectPresenter( derivedStateOf { when { roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) - isSearchActive -> SearchBarResultState.NoResultsFound + queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound else -> SearchBarResultState.Initial } } @@ -173,13 +171,11 @@ class RoomSelectPresenter( selectedRooms.adding(event.room) } } - RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive 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 { @@ -193,7 +189,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 5865d4416a..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 @@ -10,6 +10,7 @@ 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 @@ -24,18 +25,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,9 +52,11 @@ 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 +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 +78,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 +131,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,16 +140,34 @@ fun RoomSelectView( BackHandler( enabled = canHandleBack, - onBack = { onBackButton(state) } + onBack = ::onBackButton, ) + val searchQuery = state.searchQuery.text.toString() // 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.isNotBlank() + 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)) } @@ -153,13 +177,13 @@ 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 = { BackButton( enabled = canHandleBack, - onClick = { onBackButton(state) } + onClick = ::onBackButton, ) }, actions = { @@ -177,82 +201,62 @@ 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) { + 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 -> - if (summaries.isNotEmpty()) { - ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), - hasDivider = false, + ) + .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)) }, ) } - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, - ) { - 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, - ) - } - } } - - 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 -> NoResultsView(searchQuery = searchQuery) + state.resultState is SearchBarResultState.Results -> { LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, ) { if (state.resultState.results.isNotEmpty()) { item { + val headerText = if (searchQuery.isBlank()) { + stringResource(CommonStrings.common_header_rooms) + } else { + stringResource(CommonStrings.common_results_for, searchQuery) + } ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), + title = headerText, hasDivider = false, ) } @@ -269,11 +273,45 @@ fun RoomSelectView( } } } + else -> Unit } } } } +@Composable +private fun NoResultsView( + searchQuery: String, +) { + Column( + modifier = Modifier + .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 = Arrangement.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 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 aa02171d29..91e26be182 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 @@ -63,7 +63,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)) @@ -366,6 +365,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( @@ -386,18 +412,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() @@ -416,8 +430,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 543d20b521..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 @@ -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,44 @@ 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 `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( @@ -150,3 +193,10 @@ private fun AndroidComposeUiTest.setRoomSelectView( ) } } + +private fun aLongRoomList() = List(30) { index -> + aSelectRoomInfo( + roomId = RoomId("!room$index:domain"), + name = "Room $index", + ) +}.toImmutableList() 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" 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", 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/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 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/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 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 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..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:6b164aa5e1a4e8115aede74944e450dfd3abe33d13f6b91bc27745d87679a7f9 -size 13080 +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 309ace3cff..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:99beabe21f4f1dad2673e96631dd6756b78cb1c1495c7a7ba8d44257b593e007 -size 10999 +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 a3efa82e01..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:c4614a485e74a734ae29d28588f482e38124543c16169443859bbfb1052fa348 -size 36016 +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 1bd6536c16..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:7a8c5f7dd955e112736c4eeb6a0f9a52cda2f08264f50567d13e2bca8ed1fcf5 -size 33921 +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 566292b739..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:23c03d8ceeb921a6fe5ec71764c71e6cb6623ae25b6e455cd4e39e673732bab7 -size 37812 +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 4af9e7c6f5..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:9ba018ba755b9925df764eec9a42a0f18853bce9944f153e853dda5427267e54 -size 12481 +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 ac3d4a319d..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:6293fe8bfd160716a014932d7b9348fcad9cfad08a0c747bd49cf5922a5b685d -size 10732 +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 660cfc382e..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:f68669e01f3ad0e984e70876cbcb2fe54fdbe78fa579000919bb025fab05ab27 -size 35943 +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 5e079df0be..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:23a17d6f9e1e44452d4f18b31ccfd93070baba947d7d5b5d5dce1accc737c964 -size 34066 +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 4b82ca3c80..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:c6b20e6f50dc0a3a7f9086252fb1be322602cfed48c0750791ad24d88feb9ca1 -size 38032 +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