diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml index c6d4c44d80..54889c58b7 100644 --- a/.github/workflows/maestro.yml +++ b/.github/workflows/maestro.yml @@ -47,7 +47,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - - uses: mobile-dev-inc/action-maestro-cloud@v1.8.0 + - uses: mobile-dev-inc/action-maestro-cloud@v1.8.1 if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' with: api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} diff --git a/changelog.d/+improve-accessibility-in-timeline.bugfix b/changelog.d/+improve-accessibility-in-timeline.bugfix new file mode 100644 index 0000000000..a0f37c2072 --- /dev/null +++ b/changelog.d/+improve-accessibility-in-timeline.bugfix @@ -0,0 +1 @@ +Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. diff --git a/changelog.d/2256.feature b/changelog.d/2256.feature new file mode 100644 index 0000000000..e7d33d6d11 --- /dev/null +++ b/changelog.d/2256.feature @@ -0,0 +1,3 @@ +Add moderation to rooms: + +- Sort member in room member list by powerlevel, display their roles. diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt index 0fa899e033..ee64f2b428 100644 --- a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt +++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt @@ -27,7 +27,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.test.AN_AVATAR_URL @@ -38,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.push.api.notifications.NotificationDrawerManager @@ -431,7 +431,7 @@ class InviteListPresenterTests { avatarUrl = null, isDirect = false, lastMessage = null, - inviter = RoomMember( + inviter = aRoomMember( userId = A_USER_ID, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL, @@ -458,7 +458,7 @@ class InviteListPresenterTests { avatarUrl = null, isDirect = true, lastMessage = null, - inviter = RoomMember( + inviter = aRoomMember( userId = A_USER_ID, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL, diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index dd265ee7f0..515843c7bb 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -22,6 +22,11 @@ plugins { android { namespace = "io.element.android.features.location.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -51,11 +56,14 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) - testImplementation(libs.test.truth) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 84737c192b..dd0e67b1b8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -24,78 +24,47 @@ private const val APP_NAME = "ApplicationName" class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, + aShowLocationState(), + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, ), - ShowLocationState( - ShowLocationState.Dialog.PermissionDenied, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, ), - ShowLocationState( - ShowLocationState.Dialog.PermissionRationale, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, - ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, + aShowLocationState( hasLocationPermission = true, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, + aShowLocationState( hasLocationPermission = true, isTrackMyLocation = true, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "My favourite place!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "For some reason I decided to to write a small essay that wraps at just two lines!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "For some reason I decided to write a small essay in the location description. " + "It is so long that it will wrap onto more than two lines!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), ) } + +fun aShowLocationState( + permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, + location: Location = Location(1.23, 2.34, 4f), + description: String? = null, + hasLocationPermission: Boolean = false, + isTrackMyLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShowLocationEvents) -> Unit = {}, +) = ShowLocationState( + permissionDialog = permissionDialog, + location = location, + description = description, + hasLocationPermission = hasLocationPermission, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = eventSink, +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index d603bd19ae..2f352d50d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -120,10 +120,14 @@ fun ShowLocationView( ) }, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton( + onClick = onBackPressed, + ) }, actions = { - IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) { + IconButton( + onClick = { state.eventSink(ShowLocationEvents.Share) } + ) { Icon( imageVector = CompoundIcons.ShareAndroid(), contentDescription = stringResource(CommonStrings.action_share), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt new file mode 100644 index 0000000000..a66197e733 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.location.impl.show + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowLocationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShowLocationView( + state = aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test share action`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + val shareContentDescription = rule.activity.getString(CommonStrings.action_share) + rule.onNodeWithContentDescription(shareContentDescription).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.Share) + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + val shareContentDescription = rule.activity.getString(CommonStrings.action_share) + rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) + } + + @Test + fun `when permission denied is displayed user can open the settings`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) + } + + @Test + fun `when permission denied is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } + + @Test + fun `when permission rationale is displayed user can request permissions`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) + } + + @Test + fun `when permission rationale is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setShowLocationView( + state: ShowLocationState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapboxMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShowLocationView( + state = state, + onBackPressed = onBackPressed, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index 1732b36069..fd46405fe6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -152,6 +152,7 @@ internal fun MentionSuggestionsPickerView_Preview() { powerLevel = 0L, normalizedPowerLevel = 0L, isIgnored = false, + role = RoomMember.Role.USER, ) MentionSuggestionsPickerView( roomId = RoomId("!room:matrix.org"), 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 79ef6efe40..6805cd926e 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 @@ -18,9 +18,11 @@ package io.element.android.features.messages.impl.timeline +import android.view.accessibility.AccessibilityManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box @@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -64,7 +66,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.aTypingNotificationState -import io.element.android.libraries.designsystem.animation.alphaAnimation import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton @@ -99,7 +100,13 @@ fun TimelineView( state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) } + val context = LocalContext.current val lazyListState = rememberLazyListState() + // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version + val useReverseLayout = remember { + val accessibilityManager = context.getSystemService(AccessibilityManager::class.java) + accessibilityManager.isTouchExplorationEnabled.not() + } @Suppress("UNUSED_PARAMETER") fun inReplyToClicked(eventId: EventId) { @@ -107,67 +114,67 @@ fun TimelineView( } // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms - val alpha by alphaAnimation(label = "alpha for timeline") - - Box(modifier = modifier.alpha(alpha)) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - reverseLayout = true, - contentPadding = PaddingValues(vertical = 8.dp), - ) { - item { - TypingNotificationView(state = typingNotificationState) - } - items( - items = state.timelineItems, - contentType = { timelineItem -> timelineItem.contentType() }, - key = { timelineItem -> timelineItem.identifier() }, - ) { timelineItem -> - TimelineItemRow( - timelineItem = timelineItem, - timelineRoomInfo = state.timelineRoomInfo, - renderReadReceipts = state.renderReadReceipts, - isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && - state.timelineItems.first().identifier() == timelineItem.identifier(), - highlightedItem = state.highlightedEventId?.value, - onClick = onMessageClicked, - onLongClick = onMessageLongClicked, - onUserDataClick = onUserDataClicked, - inReplyToClick = ::inReplyToClicked, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, - onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, - sessionState = state.sessionState, - eventSink = state.eventSink, - onSwipeToReply = onSwipeToReply, - ) - } - if (state.paginationState.hasMoreToLoadBackwards) { - // Do not use key parameter to avoid wrong positioning - item(contentType = "TimelineLoadingMoreIndicator") { - TimelineLoadingMoreIndicator() - LaunchedEffect(Unit) { - onReachedLoadMore() + AnimatedVisibility(visible = true, enter = fadeIn()) { + Box(modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + reverseLayout = useReverseLayout, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + item { + TypingNotificationView(state = typingNotificationState) + } + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineRoomInfo = state.timelineRoomInfo, + renderReadReceipts = state.renderReadReceipts, + isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && + state.timelineItems.first().identifier() == timelineItem.identifier(), + highlightedItem = state.highlightedEventId?.value, + onClick = onMessageClicked, + onLongClick = onMessageLongClicked, + onUserDataClick = onUserDataClicked, + inReplyToClick = ::inReplyToClicked, + onReactionClick = onReactionClicked, + onReactionLongClick = onReactionLongClicked, + onMoreReactionsClick = onMoreReactionsClicked, + onReadReceiptClick = onReadReceiptClick, + onTimestampClicked = onTimestampClicked, + sessionState = state.sessionState, + eventSink = state.eventSink, + onSwipeToReply = onSwipeToReply, + ) + } + if (state.paginationState.hasMoreToLoadBackwards) { + // Do not use key parameter to avoid wrong positioning + item(contentType = "TimelineLoadingMoreIndicator") { + TimelineLoadingMoreIndicator() + LaunchedEffect(Unit) { + onReachedLoadMore() + } + } + } + if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) { + item(contentType = "BeginningOfRoomReached") { + TimelineItemRoomBeginningView(roomName = roomName) } } } - if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) { - item(contentType = "BeginningOfRoomReached") { - TimelineItemRoomBeginningView(roomName = roomName) - } - } - } - TimelineScrollHelper( - isTimelineEmpty = state.timelineItems.isEmpty(), - lazyListState = lazyListState, - forceJumpToBottomVisibility = forceJumpToBottomVisibility, - newEventState = state.newEventState, - onScrollFinishedAt = ::onScrollFinishedAt - ) + TimelineScrollHelper( + isTimelineEmpty = state.timelineItems.isEmpty(), + lazyListState = lazyListState, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + newEventState = state.newEventState, + onScrollFinishedAt = ::onScrollFinishedAt + ) + } } } 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 146827085d..75f4a7ce6d 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 @@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -53,6 +54,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -107,6 +113,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch import kotlin.math.abs @@ -256,6 +263,7 @@ private fun SwipeSensitivity( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun TimelineItemEventRowContent( event: TimelineItem.Event, @@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent( .padding(horizontal = 16.dp) .zIndex(1f) .clickable(onClick = onUserDataClicked) + // This is redundant when using talkback + .clearAndSetSemantics { + invisibleToUser() + testTag = TestTags.timelineItemSenderInfo.value + } ) } @@ -413,6 +426,7 @@ private fun MessageSenderInformation( private fun MessageEventBubbleContent( event: TimelineItem.Event, onMessageLongClick: () -> Unit, + @Suppress("UNUSED_PARAMETER") inReplyToClick: () -> Unit, onTimestampClicked: () -> Unit, onMentionClicked: (Mention) -> Unit, @@ -445,6 +459,7 @@ private fun MessageEventBubbleContent( text = stringResource(CommonStrings.common_thread), style = ElementTheme.typography.fontBodyXsRegular, color = ElementTheme.colors.textPrimary, + modifier = Modifier.clearAndSetSemantics { } ) } } @@ -580,7 +595,8 @@ private fun MessageEventBubbleContent( modifier = Modifier .padding(top = topPadding, start = 8.dp, end = 8.dp) .clip(RoundedCornerShape(6.dp)) - .clickable(enabled = true, onClick = inReplyToClick), + // FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent +// .clickable(enabled = true, onClick = inReplyToClick) ) } if (inReplyToDetails != null) { @@ -611,7 +627,9 @@ private fun MessageEventBubbleContent( timestampPosition = timestampPosition, inReplyToDetails = event.inReplyTo, canShrinkContent = event.content is TimelineItemVoiceContent, - modifier = bubbleModifier + modifier = bubbleModifier.semantics(mergeDescendants = true) { + contentDescription = event.safeSenderName + } ) } @@ -641,8 +659,12 @@ private fun ReplyToContent( ) Spacer(modifier = Modifier.width(8.dp)) } + val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName) Column(verticalArrangement = Arrangement.SpaceBetween) { Text( + modifier = Modifier.semantics { + contentDescription = a11InReplyToText + }, text = senderName, style = ElementTheme.typography.fontBodySmMedium, textAlign = TextAlign.Start, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 7bebd9e6d7..d058df9996 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -18,6 +18,9 @@ package io.element.android.features.messages.impl.timeline.components.event import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider @@ -25,15 +28,17 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemImageView( content: TimelineItemImageContent, modifier: Modifier = Modifier, ) { + val description = stringResource(CommonStrings.common_image) TimelineItemAspectRatioBox( aspectRatio = content.aspectRatio, - modifier = modifier, + modifier = modifier.semantics { contentDescription = description }, ) { BlurHashAsyncImage( model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 2df3766175..c7e1b37bb2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -23,6 +23,8 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout @@ -48,7 +50,7 @@ fun TimelineItemTextView( val formattedBody = content.formattedBody val body = SpannableString(formattedBody ?: content.body) - Box(modifier) { + Box(modifier.semantics { contentDescription = body.toString() }) { EditorStyledText( text = body, onLinkClickedListener = onLinkClicked, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 4900180800..d017446539 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider @@ -42,9 +44,10 @@ fun TimelineItemVideoView( content: TimelineItemVideoContent, modifier: Modifier = Modifier, ) { + val description = stringResource(CommonStrings.common_image) TimelineItemAspectRatioBox( aspectRatio = content.aspectRatio, - modifier = modifier, + modifier = modifier.semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { BlurHashAsyncImage( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt index 96ed7bad77..d8a48037fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt @@ -103,5 +103,6 @@ private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember { powerLevel = 0, normalizedPowerLevel = 0, isIgnored = false, + role = RoomMember.Role.USER, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt index b610cc45f9..63f9f66e3a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt @@ -98,5 +98,6 @@ internal fun aTypingRoomMember( powerLevel = 0, normalizedPowerLevel = 0, isIgnored = false, + role = RoomMember.Role.USER, ) } 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 e1236b805d..c1ce011cea 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 @@ -49,6 +49,7 @@ import io.element.android.features.messages.impl.voicemessages.timeline.FakeReda import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper @@ -98,6 +99,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -372,6 +374,22 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action end poll`() = runTest { + val endPollAction = FakeEndPollAction() + val presenter = createMessagesPresenter(endPollAction = endPollAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + endPollAction.verifyExecutionCount(0) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent()))) + delay(1) + endPollAction.verifyExecutionCount(1) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - handle action redact`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) @@ -683,6 +701,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + endPollAction: EndPollAction = FakeEndPollAction(), ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) @@ -721,7 +740,7 @@ class MessagesPresenterTest { encryptionService = FakeEncryptionService(), verificationService = FakeSessionVerificationService(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), - endPollAction = FakeEndPollAction(), + endPollAction = endPollAction, sendPollResponseAction = FakeSendPollResponseAction(), sessionPreferencesStore = sessionPreferencesStore, ) 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 144cb14561..b931dc9860 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 @@ -326,8 +326,7 @@ class MessagesViewTest { state = state, onUserDataClicked = callback, ) - val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty() - rule.onNodeWithText(senderName).performClick() + rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index c189e28c13..5e980514cb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -87,10 +87,13 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import uniffi.wysiwyg_composer.MentionsState import java.io.File @Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) class MessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -875,6 +878,19 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - send uri`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.richTextEditorState.messageHtml) { state } + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) + waitForPredicate { mediaPreProcessor.processCallCount == 1 } + } + } + @Test fun `present - handle typing notice event`() = runTest { val room = FakeMatrixRoom() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt index 51b642ea68..aaf0f9f774 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -26,8 +26,6 @@ import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesS import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState -import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.A_USER_ID_3 @@ -178,7 +176,6 @@ class TypingNotificationPresenterTest { @Test fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest { - val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2) val room = FakeMatrixRoom() val presenter = createPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { @@ -216,27 +213,17 @@ class TypingNotificationPresenterTest { private fun createDefaultRoomMember( userId: UserId, - ) = RoomMember( + ) = aTypingRoomMember( userId = userId, displayName = null, - avatarUrl = null, - membership = RoomMembershipState.JOIN, isNameAmbiguous = false, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, ) private fun createKnownRoomMember( userId: UserId, - ) = RoomMember( + ) = aTypingRoomMember( userId = userId, displayName = "Alice Doe", - avatarUrl = "an_avatar_url", - membership = RoomMembershipState.JOIN, isNameAmbiguous = true, - powerLevel = 0, - normalizedPowerLevel = 0, - isIgnored = false, ) } diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 0f1a139f40..8f1cfdc2f0 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -23,6 +23,11 @@ plugins { android { namespace = "io.element.android.features.roomdetails.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -61,6 +66,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) @@ -70,6 +76,8 @@ dependencies { testImplementation(projects.tests.testutils) testImplementation(projects.features.leaveroom.test) testImplementation(projects.features.createroom.test) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) ksp(libs.showkase.processor) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 2493efc855..292ad43d0b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -55,6 +55,7 @@ fun aDmRoomMember( powerLevel: Long = 0, normalizedPowerLevel: Long = powerLevel, isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.USER, ) = RoomMember( userId = userId, displayName = displayName, @@ -64,6 +65,7 @@ fun aDmRoomMember( powerLevel = powerLevel, normalizedPowerLevel = normalizedPowerLevel, isIgnored = isIgnored, + role = role, ) fun aRoomDetailsState() = RoomDetailsState( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt index ddbff5b6a9..be25a6228f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt @@ -55,7 +55,10 @@ fun BlockUserDialogs(state: RoomMemberDetailsState) { } @Composable -private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) { +private fun BlockConfirmationDialog( + onBlockAction: () -> Unit, + onDismiss: () -> Unit, +) { ConfirmationDialog( title = stringResource(R.string.screen_dm_details_block_user), content = stringResource(R.string.screen_dm_details_block_alert_description), @@ -66,7 +69,10 @@ private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> } @Composable -private fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) { +private fun UnblockConfirmationDialog( + onUnblockAction: () -> Unit, + onDismiss: () -> Unit, +) { ConfirmationDialog( title = stringResource(R.string.screen_dm_details_unblock_user), content = stringResource(R.string.screen_dm_details_unblock_alert_description), diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/PowerLevelRoomMemberComparator.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/PowerLevelRoomMemberComparator.kt new file mode 100644 index 0000000000..51846f4bb7 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/PowerLevelRoomMemberComparator.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.impl.members + +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.ui.room.sortingName +import java.text.Collator + +// Comparator used to sort room members by power level (descending) and then by name (ascending) +internal class PowerLevelRoomMemberComparator : Comparator { + // Used to simplify and compare unicode and ASCII chars (รก == a) + private val collator = Collator.getInstance().apply { + decomposition = Collator.CANONICAL_DECOMPOSITION + } + override fun compare(o1: RoomMember, o2: RoomMember): Int { + return when { + o1.powerLevel > o2.powerLevel -> return -1 + o1.powerLevel < o2.powerLevel -> return 1 + else -> { + collator.compare(o1.sortingName(), o2.sortingName()) + } + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 399a2ad1e1..29800f6eac 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -66,7 +66,9 @@ class RoomMemberListPresenter @Inject constructor( roomMembers = AsyncData.Success( RoomMembers( invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), - joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), + joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) + .sortedWith(PowerLevelRoomMemberComparator()) + .toImmutableList(), ) ) } @@ -84,7 +86,9 @@ class RoomMemberListPresenter @Inject constructor( SearchBarResultState.Results( RoomMembers( invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), - joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), + joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()) + .sortedWith(PowerLevelRoomMemberComparator()) + .toImmutableList(), ) ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index 5d9549808c..9846401323 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -31,7 +31,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { + val roleText = when (roomMember.role) { + RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator) + RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator) + RoomMember.Role.USER -> null + } MatrixUserRow( modifier = modifier.clickable(onClick = onClick), matrixUser = MatrixUser( userId = roomMember.userId, displayName = roomMember.displayName, - avatarUrl = roomMember.avatarUrl + avatarUrl = roomMember.avatarUrl, ), avatarSize = AvatarSize.UserListItem, + trailingContent = roleText?.let { + @Composable { + Text( + text = it, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt index ff4b9ee1b3..dfc208e047 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -19,28 +19,38 @@ package io.element.android.features.roomdetails.impl.members.details import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId open class RoomMemberDetailsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomMemberDetailsState(), - aRoomMemberDetailsState().copy(userName = null), - aRoomMemberDetailsState().copy(isBlocked = AsyncData.Success(true)), - aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), - aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), - aRoomMemberDetailsState().copy(isBlocked = AsyncData.Loading(true)), - aRoomMemberDetailsState().copy(startDmActionState = AsyncAction.Loading), + aRoomMemberDetailsState(userName = null), + aRoomMemberDetailsState(isBlocked = AsyncData.Success(true)), + aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), + aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), + aRoomMemberDetailsState(isBlocked = AsyncData.Loading(true)), + aRoomMemberDetailsState(startDmActionState = AsyncAction.Loading), // Add other states here ) } -fun aRoomMemberDetailsState() = RoomMemberDetailsState( - userId = "@daniel:domain.com", - userName = "Daniel", - avatarUrl = null, - isBlocked = AsyncData.Success(false), - startDmActionState = AsyncAction.Uninitialized, - displayConfirmationDialog = null, - isCurrentUser = false, - eventSink = {}, +fun aRoomMemberDetailsState( + userId: String = "@daniel:domain.com", + userName: String? = "Daniel", + avatarUrl: String? = null, + isBlocked: AsyncData = AsyncData.Success(false), + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + displayConfirmationDialog: RoomMemberDetailsState.ConfirmationDialog? = null, + isCurrentUser: Boolean = false, + eventSink: (RoomMemberDetailsEvents) -> Unit = {}, +) = RoomMemberDetailsState( + userId = userId, + userName = userName, + avatarUrl = avatarUrl, + isBlocked = isBlocked, + startDmActionState = startDmActionState, + displayConfirmationDialog = displayConfirmationDialog, + isCurrentUser = isCurrentUser, + eventSink = eventSink, ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt new file mode 100644 index 0000000000..5683b88c3c --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.impl.blockuser + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdetails.impl.R +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState +import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockUserDialogsTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `confirm block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_block_alert_action) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.BlockUser(false)) + } + + @Test + fun `cancel block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog) + } + + @Test + fun `confirm unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_unblock_alert_action) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.UnblockUser(false)) + } + + @Test + fun `cancel unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/PowerLevelRoomMemberComparatorTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/PowerLevelRoomMemberComparatorTest.kt new file mode 100644 index 0000000000..aa399465a0 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/PowerLevelRoomMemberComparatorTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.members + +import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.libraries.matrix.api.core.UserId +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.A_USER_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.A_USER_ID_5 +import org.junit.Test + +class PowerLevelRoomMemberComparatorTest { + @Test + fun `order is Admin, then Moderator, then User`() { + val memberList = listOf( + aRoomMember(userId = UserId("@admin:example.com"), powerLevel = 100), + aRoomMember(userId = UserId("@moderator:example.com"), powerLevel = 50), + aRoomMember(userId = UserId("@user:example.com"), powerLevel = 0), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == UserId("@admin:example.com")) + assert(ordered[1].userId == UserId("@moderator:example.com")) + assert(ordered[2].userId == UserId("@user:example.com")) + } + + @Test + fun `with the same power level, alphabetical ascending order for name is used`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "First - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, displayName = "Second - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, displayName = "Third - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_4, displayName = "First - user", powerLevel = 0), + aRoomMember(userId = A_USER_ID_5, displayName = "Second - user", powerLevel = 0), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID) + assert(ordered[1].userId == A_USER_ID_2) + assert(ordered[2].userId == A_USER_ID_3) + assert(ordered[3].userId == A_USER_ID_4) + assert(ordered[4].userId == A_USER_ID_5) + } + + @Test + fun `when no names are provided, alphabetical order uses user id`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "Z - LAST!", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, powerLevel = 100), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID_2) + assert(ordered[1].userId == A_USER_ID_3) + assert(ordered[2].userId == A_USER_ID) + } + + @Test + fun `unicode characters are simplified and compared, order ignores case`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "First", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, displayName = "ศ˜econd", powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, displayName = "third", powerLevel = 100), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID) + assert(ordered[1].userId == A_USER_ID_2) + assert(ordered[2].userId == A_USER_ID_3) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index c808ae689b..cad5dd3311 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.libraries.matrix.api.core.RoomId sealed interface RoomListEvents { - data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data object DismissRequestVerificationPrompt : RoomListEvents data object DismissRecoveryKeyPrompt : RoomListEvents diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index af12f627f0..ed5f3b1ea1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -38,6 +38,8 @@ import io.element.android.features.roomlist.impl.datasource.InviteStateDataSourc import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter +import io.element.android.features.roomlist.impl.search.RoomListSearchEvents +import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -74,13 +76,15 @@ class RoomListPresenter @Inject constructor( private val inviteStateDataSource: InviteStateDataSource, private val leaveRoomPresenter: LeaveRoomPresenter, private val roomListDataSource: RoomListDataSource, - private val encryptionService: EncryptionService, private val featureFlagService: FeatureFlagService, private val indicatorService: IndicatorService, private val filtersPresenter: RoomListFiltersPresenter, + private val searchPresenter: Presenter, private val migrationScreenPresenter: MigrationScreenPresenter, private val sessionPreferencesStore: SessionPreferencesStore, ) : Presenter { + private val encryptionService: EncryptionService = client.encryptionService() + @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() @@ -91,10 +95,10 @@ class RoomListPresenter @Inject constructor( val roomList by produceState(initialValue = AsyncData.Loading()) { roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } } - val filteredRoomList by roomListDataSource.filteredRooms.collectAsState() - val filter by roomListDataSource.filter.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState() + val filtersState = filtersPresenter.present() + val searchState = searchPresenter.present() LaunchedEffect(Unit) { roomListDataSource.launchIn(this) @@ -125,21 +129,14 @@ class RoomListPresenter @Inject constructor( // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() - var displaySearchResults by rememberSaveable { mutableStateOf(false) } val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } fun handleEvents(event: RoomListEvents) { when (event) { - is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true - RoomListEvents.ToggleSearchResults -> { - if (displaySearchResults) { - roomListDataSource.updateFilter("") - } - displaySearchResults = !displaySearchResults - } + RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) is RoomListEvents.ShowContextMenu -> { coroutineScope.showContextMenu(event, contextMenu) } @@ -147,7 +144,6 @@ class RoomListPresenter @Inject constructor( contextMenu.value = RoomListState.ContextMenu.Hidden } is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) - is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch { client.getRoom(event.roomId)?.use { room -> room.setIsFavorite(event.isFavorite) @@ -178,17 +174,15 @@ class RoomListPresenter @Inject constructor( matrixUser = matrixUser.value, showAvatarIndicator = showAvatarIndicator, roomList = roomList, - filter = filter, - filteredRoomList = filteredRoomList, displayVerificationPrompt = displayVerificationPrompt, displayRecoveryKeyPrompt = displayRecoveryKeyPrompt, snackbarMessage = snackbarMessage, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, invitesState = inviteStateDataSource.inviteState(), - displaySearchResults = displaySearchResults, contextMenu = contextMenu.value, leaveRoomState = leaveRoomState, filtersState = filtersState, + searchState = searchState, displayMigrationStatus = isMigrating, eventSink = ::handleEvents, ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index dd29142424..8e5dd3727e 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomId @@ -31,17 +32,15 @@ data class RoomListState( val matrixUser: MatrixUser?, val showAvatarIndicator: Boolean, val roomList: AsyncData>, - val filter: String?, - val filteredRoomList: ImmutableList, val displayVerificationPrompt: Boolean, val displayRecoveryKeyPrompt: Boolean, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, val invitesState: InvitesState, - val displaySearchResults: Boolean, val contextMenu: ContextMenu, val leaveRoomState: LeaveRoomState, val filtersState: RoomListFiltersState, + val searchState: RoomListSearchState, val displayMigrationStatus: Boolean, val eventSink: (RoomListEvents) -> Unit, ) { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 140e5d2e13..7fadd724e3 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -22,6 +22,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary +import io.element.android.features.roomlist.impl.search.aRoomListSearchState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -42,14 +43,13 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState().copy(hasNetworkConnection = false), aRoomListState().copy(invitesState = InvitesState.SeenInvites), aRoomListState().copy(invitesState = InvitesState.NewInvites), - aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), - aRoomListState().copy(displaySearchResults = true), aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState().copy(displayRecoveryKeyPrompt = true), aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState().copy(matrixUser = null, displayMigrationStatus = true), + aRoomListState().copy(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), ) } @@ -57,17 +57,15 @@ internal fun aRoomListState() = RoomListState( matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), showAvatarIndicator = false, roomList = AsyncData.Success(aRoomListRoomSummaryList()), - filter = "filter", - filteredRoomList = aRoomListRoomSummaryList(), hasNetworkConnection = true, snackbarMessage = null, displayVerificationPrompt = false, displayRecoveryKeyPrompt = false, invitesState = InvitesState.NoInvites, - displaySearchResults = false, contextMenu = RoomListState.ContextMenu.Hidden, leaveRoomState = aLeaveRoomState(), filtersState = aRoomListFiltersState(), + searchState = aRoomListSearchState(), displayMigrationStatus = false, eventSink = {} ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 0ac0d69559..a431689f2b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -58,7 +58,7 @@ import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.filters.RoomListFiltersView import io.element.android.features.roomlist.impl.migration.MigrationScreenView import io.element.android.features.roomlist.impl.model.RoomListRoomSummary -import io.element.android.features.roomlist.impl.search.RoomListSearchResultView +import io.element.android.features.roomlist.impl.search.RoomListSearchView import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -119,8 +119,8 @@ fun RoomListView( onMenuActionClicked = onMenuActionClicked, ) // This overlaid view will only be visible when state.displaySearchResults is true - RoomListSearchResultView( - state = state, + RoomListSearchView( + state = state.searchState, onRoomClicked = onRoomClicked, onRoomLongClicked = { onRoomLongClicked(it) }, modifier = Modifier @@ -209,8 +209,7 @@ private fun RoomListContent( RoomListTopBar( matrixUser = state.matrixUser, showAvatarIndicator = state.showAvatarIndicator, - areSearchResultsDisplayed = state.displaySearchResults, - onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, + areSearchResultsDisplayed = state.searchState.isSearchActive, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, onMenuActionClicked = onMenuActionClicked, onOpenSettings = onOpenSettings, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index 871ca846c7..0d0a750a9f 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -16,7 +16,6 @@ package io.element.android.features.roomlist.impl.components -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -87,7 +86,6 @@ fun RoomListTopBar( matrixUser: MatrixUser?, showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, - onFilterChanged: (String) -> Unit, onToggleSearch: () -> Unit, onMenuActionClicked: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, @@ -95,15 +93,6 @@ fun RoomListTopBar( displayMenuItems: Boolean, modifier: Modifier = Modifier, ) { - fun closeFilter() { - onFilterChanged("") - } - - BackHandler(enabled = areSearchResultsDisplayed) { - closeFilter() - onToggleSearch() - } - DefaultRoomListTopBar( matrixUser = matrixUser, showAvatarIndicator = showAvatarIndicator, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt index c55a47596b..da03122134 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -25,15 +25,11 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -54,9 +50,7 @@ class RoomListDataSource @Inject constructor( observeNotificationSettings() } - private val _filter = MutableStateFlow("") private val _allRooms = MutableSharedFlow>(replay = 1) - private val _filteredRooms = MutableStateFlow>(persistentListOf()) private val lock = Mutex() private val diffCache = MutableListDiffCache() @@ -72,29 +66,9 @@ class RoomListDataSource @Inject constructor( replaceWith(roomSummaries) } .launchIn(coroutineScope) - - combine( - _filter, - _allRooms - ) { filterValue, allRoomsValue -> - when { - filterValue.isEmpty() -> emptyList() - else -> allRoomsValue.filter { it.name.contains(filterValue, ignoreCase = true) } - }.toImmutableList() - } - .onEach { - _filteredRooms.value = it - } - .launchIn(coroutineScope) } - fun updateFilter(filterValue: String) { - _filter.value = filterValue - } - - val filter: StateFlow = _filter val allRooms: SharedFlow> = _allRooms - val filteredRooms: StateFlow> = _filteredRooms @OptIn(FlowPreview::class) private fun observeNotificationSettings() { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/di/RoomListModule.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/di/RoomListModule.kt new file mode 100644 index 0000000000..369da2fc75 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/di/RoomListModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter +import io.element.android.features.roomlist.impl.search.RoomListSearchState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@Module +interface RoomListModule { + @Binds + fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt new file mode 100644 index 0000000000..6c99a4e0d0 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.search + +sealed interface RoomListSearchEvents { + data object ToggleSearchVisibility : RoomListSearchEvents + data class QueryChanged(val query: String) : RoomListSearchEvents + data object ClearQuery : RoomListSearchEvents +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt new file mode 100644 index 0000000000..6917a505c3 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.search + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private const val PAGE_SIZE = 50 + +class RoomListSearchPresenter @Inject constructor( + private val roomListService: RoomListService, + private val roomSummaryFactory: RoomListRoomSummaryFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) : Presenter { + @Composable + override fun present(): RoomListSearchState { + var isSearchActive by rememberSaveable { + mutableStateOf(false) + } + var searchQuery by rememberSaveable { + mutableStateOf("") + } + val coroutineScope = rememberCoroutineScope() + + val roomList = remember { + roomListService.createRoomList( + coroutineScope = coroutineScope, + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.all(RoomListFilter.None), + source = RoomList.Source.All, + ) + } + + LaunchedEffect(Unit) { + roomList.loadAllIncrementally(this) + } + LaunchedEffect(key1 = searchQuery) { + val filter = if (searchQuery.isBlank()) { + RoomListFilter.all(RoomListFilter.None) + } else { + RoomListFilter.all(RoomListFilter.NonLeft, RoomListFilter.NormalizedMatchRoomName(searchQuery)) + } + roomList.updateFilter(filter) + } + + fun handleEvents(event: RoomListSearchEvents) { + when (event) { + RoomListSearchEvents.ClearQuery -> { + searchQuery = "" + } + is RoomListSearchEvents.QueryChanged -> { + searchQuery = event.query + } + RoomListSearchEvents.ToggleSearchVisibility -> { + isSearchActive = !isSearchActive + searchQuery = "" + } + } + } + + val searchResults by roomList + .rememberMappedSummaries() + .collectAsState(initial = persistentListOf()) + + return RoomListSearchState( + isSearchActive = isSearchActive, + query = searchQuery, + results = searchResults, + eventSink = ::handleEvents + ) + } + + @Composable + private fun RoomList.rememberMappedSummaries() = remember { + summaries + .map { roomSummaries -> + roomSummaries + .filterIsInstance() + .map(roomSummaryFactory::create) + .toPersistentList() + } + .flowOn(coroutineDispatchers.computation) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt new file mode 100644 index 0000000000..c4b24dc798 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.search + +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import kotlinx.collections.immutable.ImmutableList + +data class RoomListSearchState( + val isSearchActive: Boolean, + val query: String, + val results: ImmutableList, + val eventSink: (RoomListSearchEvents) -> Unit +) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt new file mode 100644 index 0000000000..ae722a4b04 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.search + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomlist.impl.aRoomListRoomSummaryList +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class RoomListSearchStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomListSearchState(), + aRoomListSearchState( + isSearchActive = true, + query = "Test", + results = aRoomListRoomSummaryList() + ), + ) +} + +fun aRoomListSearchState( + isSearchActive: Boolean = false, + query: String = "", + results: ImmutableList = persistentListOf(), + eventSink: (RoomListSearchEvents) -> Unit = { }, +) = RoomListSearchState( + isSearchActive = isSearchActive, + query = query, + results = results, + eventSink = eventSink, +) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchResultView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt similarity index 73% rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchResultView.kt rename to features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt index 2cf63393f9..eff6449449 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchResultView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt @@ -16,6 +16,7 @@ package io.element.android.features.roomlist.impl.search +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -25,32 +26,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.roomlist.impl.RoomListEvents -import io.element.android.features.roomlist.impl.RoomListState -import io.element.android.features.roomlist.impl.aRoomListState import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.contentType import io.element.android.features.roomlist.impl.model.RoomListRoomSummary @@ -68,26 +60,30 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.ui.strings.CommonStrings @Composable -internal fun RoomListSearchResultView( - state: RoomListState, +internal fun RoomListSearchView( + state: RoomListSearchState, onRoomClicked: (RoomId) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { + BackHandler(enabled = state.isSearchActive) { + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + AnimatedVisibility( - visible = state.displaySearchResults, + visible = state.isSearchActive, enter = fadeIn(), exit = fadeOut(), ) { Column( modifier = modifier - .applyIf(state.displaySearchResults, ifTrue = { + .applyIf(state.isSearchActive, ifTrue = { // Disable input interaction to underlying views pointerInput(Unit) {} }) ) { - if (state.displaySearchResults) { - RoomListSearchResultContent( + if (state.isSearchActive) { + RoomListSearchContent( state = state, onRoomClicked = onRoomClicked, onRoomLongClicked = onRoomLongClicked, @@ -99,15 +95,15 @@ internal fun RoomListSearchResultView( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RoomListSearchResultContent( - state: RoomListState, +private fun RoomListSearchContent( + state: RoomListSearchState, onRoomClicked: (RoomId) -> Unit, onRoomLongClicked: (RoomListRoomSummary) -> Unit, ) { val borderColor = MaterialTheme.colorScheme.tertiary val strokeWidth = 1.dp fun onBackButtonPressed() { - state.eventSink(RoomListEvents.ToggleSearchResults) + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) } fun onRoomClicked(room: RoomListRoomSummary) { @@ -126,7 +122,7 @@ private fun RoomListSearchResultContent( }, navigationIcon = { BackButton(onClick = ::onBackButtonPressed) }, title = { - val filter = state.filter.orEmpty() + val filter = state.query val focusRequester = FocusRequester() TextField( modifier = Modifier @@ -134,7 +130,7 @@ private fun RoomListSearchResultContent( .focusRequester(focusRequester), value = filter, singleLine = true, - onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, + onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) }, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, @@ -147,7 +143,7 @@ private fun RoomListSearchResultContent( trailingIcon = { if (filter.isNotEmpty()) { IconButton(onClick = { - state.eventSink(RoomListEvents.UpdateFilter("")) + state.eventSink(RoomListSearchEvents.ClearQuery) }) { Icon( imageVector = CompoundIcons.Close(), @@ -158,8 +154,8 @@ private fun RoomListSearchResultContent( } ) - LaunchedEffect(state.displaySearchResults) { - if (state.displaySearchResults) { + LaunchedEffect(state.isSearchActive) { + if (state.isSearchActive) { focusRequester.requestFocus() } } @@ -168,39 +164,16 @@ private fun RoomListSearchResultContent( ) } ) { padding -> - val lazyListState = rememberLazyListState() - val visibleRange by remember { - derivedStateOf { - val layoutInfo = lazyListState.layoutInfo - val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0 - val size = layoutInfo.visibleItemsInfo.size - firstItemIndex until firstItemIndex + size - } - } - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override suspend fun onPostFling( - consumed: Velocity, - available: Velocity - ): Velocity { - state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange)) - return super.onPostFling(consumed, available) - } - } - } Column( modifier = Modifier .padding(padding) .consumeWindowInsets(padding) ) { LazyColumn( - modifier = Modifier - .weight(1f) - .nestedScroll(nestedScrollConnection), - state = lazyListState, + modifier = Modifier.weight(1f), ) { items( - items = state.filteredRoomList, + items = state.results, contentType = { room -> room.contentType() }, ) { room -> RoomSummaryRow( @@ -216,9 +189,9 @@ private fun RoomListSearchResultContent( @PreviewsDayNight @Composable -internal fun RoomListSearchResultContentPreview() = ElementPreview { - RoomListSearchResultContent( - state = aRoomListState(), +internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { + RoomListSearchContent( + state = state, onRoomClicked = {}, onRoomLongClicked = {} ) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index fdf9deeec0..d8c8e3923a 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -34,19 +34,24 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresente import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary +import io.element.android.features.roomlist.impl.search.RoomListSearchEvents +import io.element.android.features.roomlist.impl.search.RoomListSearchState +import io.element.android.features.roomlist.impl.search.aRoomListSearchState +import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupState -import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.ReceiptType @@ -55,7 +60,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME @@ -67,6 +71,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.testCoroutineDispatchers @@ -78,6 +83,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.seconds class RoomListPresenterTests { @get:Rule @@ -106,9 +112,12 @@ class RoomListPresenterTests { fun `present - show avatar indicator`() = runTest { val scope = CoroutineScope(coroutineContext + SupervisorJob()) val encryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient( + encryptionService = encryptionService, + ) val sessionVerificationService = FakeSessionVerificationService() val presenter = createRoomListPresenter( - encryptionService = encryptionService, + client = matrixClient, sessionVerificationService = sessionVerificationService, coroutineScope = scope ) @@ -146,24 +155,6 @@ class RoomListPresenterTests { } } - @Test - fun `present - should filter room with success`() = runTest { - val scope = CoroutineScope(coroutineContext + SupervisorJob()) - val presenter = createRoomListPresenter(coroutineScope = scope) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - val withUserState = awaitItem() - assertThat(withUserState.filter).isEqualTo("") - withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) - val withFilterState = awaitItem() - assertThat(withFilterState.filter).isEqualTo("t") - cancelAndIgnoreRemainingEvents() - scope.cancel() - } - } - @Test fun `present - load 1 room with success`() = runTest { val roomListService = FakeRoomListService() @@ -175,7 +166,7 @@ class RoomListPresenterTests { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 16 }.last() + val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last() // Room list is loaded with 16 placeholders val initialItems = initialState.roomList.dataOrNull().orEmpty() assertThat(initialItems.size).isEqualTo(16) @@ -197,51 +188,7 @@ class RoomListPresenterTests { numberOfUnreadMessages = 2, ) ) - scope.cancel() - } - } - - @Test - fun `present - load 1 room with success and filter rooms`() = runTest { - val roomListService = FakeRoomListService() - val matrixClient = FakeMatrixClient( - roomListService = roomListService - ) - val scope = CoroutineScope(coroutineContext + SupervisorJob()) - val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - roomListService.postAllRooms( - listOf( - aRoomSummaryFilled( - numUnreadMentions = 1, - numUnreadMessages = 2, - ) - ) - ) - skipItems(3) - val loadedState = awaitItem() - // Test filtering with result - assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1) - loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) - skipItems(1) - val withFilteredRoomState = awaitItem() - assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1) - assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) - assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1) - assertThat(withFilteredRoomState.filteredRoomList.first()).isEqualTo( - createRoomListRoomSummary( - numberOfUnreadMentions = 1, - numberOfUnreadMessages = 2, - ) - ) - // Test filtering without result - withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) - skipItems(1) - val withNotFilteredRoomState = awaitItem() - assertThat(withNotFilteredRoomState.filter).isEqualTo("tada") - assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty() + cancelAndIgnoreRemainingEvents() scope.cancel() } } @@ -315,6 +262,33 @@ class RoomListPresenterTests { } } + @Test + fun `present - handle DismissRecoveryKeyPrompt`() = runTest { + val encryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient( + encryptionService = encryptionService, + ) + val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + client = matrixClient, + coroutineScope = scope, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.displayRecoveryKeyPrompt).isFalse() + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + val nextState = awaitItem() + assertThat(nextState.displayRecoveryKeyPrompt).isTrue() + nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) + val finalState = awaitItem() + assertThat(finalState.displayRecoveryKeyPrompt).isFalse() + scope.cancel() + } + } + @Test fun `present - sets invite state`() = runTest { val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) @@ -443,6 +417,40 @@ class RoomListPresenterTests { } } + @Test + fun `present - toggle search menu`() = runTest { + val eventRecorder = EventsRecorder() + val searchPresenter: Presenter = Presenter { + aRoomListSearchState( + eventSink = eventRecorder + ) + } + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + coroutineScope = scope, + searchPresenter = searchPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + eventRecorder.assertEmpty() + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertSingle( + RoomListSearchEvents.ToggleSearchVisibility + ) + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertList( + listOf( + RoomListSearchEvents.ToggleSearchVisibility, + RoomListSearchEvents.ToggleSearchVisibility + ) + ) + scope.cancel() + } + } + @Test fun `present - change in notification settings updates the summary for decorations`() = runTest { val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY @@ -516,7 +524,6 @@ class RoomListPresenterTests { // The migration screen is not shown anymore assertThat(awaitItem().displayMigrationStatus).isFalse() - cancelAndIgnoreRemainingEvents() scope.cancel() } } @@ -567,14 +574,15 @@ class RoomListPresenterTests { givenFormat(A_FORMATTED_DATE) }, roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), - encryptionService: EncryptionService = FakeEncryptionService(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), coroutineScope: CoroutineScope, migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( matrixClient = client, migrationScreenStore = InMemoryMigrationScreenStore(), ), filtersPresenter: RoomListFiltersPresenter = RoomListFiltersPresenter(), + searchPresenter: Presenter = Presenter { aRoomListSearchState() }, ) = RoomListPresenter( client = client, sessionVerificationService = sessionVerificationService, @@ -592,14 +600,14 @@ class RoomListPresenterTests { notificationSettingsService = client.notificationSettingsService(), appScope = coroutineScope ), - encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + featureFlagService = featureFlagService, indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, - encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + encryptionService = client.encryptionService(), + featureFlagService = featureFlagService, ), migrationScreenPresenter = migrationScreenPresenter, + searchPresenter = searchPresenter, sessionPreferencesStore = sessionPreferencesStore, filtersPresenter = filtersPresenter, ) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt new file mode 100644 index 0000000000..fb124356e1 --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.search + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory +import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomListSearchPresenterTests { + @Test + fun `present - initial state`() = runTest { + val presenter = createRoomListSearchPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + assertThat(state.query).isEmpty() + assertThat(state.results).isEmpty() + } + } + } + + @Test + fun `present - toggle search visibility`() = runTest { + val presenter = createRoomListSearchPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isTrue() + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + } + } + } + + @Test + fun `present - query search changes`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListSearchPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.all( + RoomListFilter.None, + ) + ) + state.eventSink(RoomListSearchEvents.QueryChanged("Search")) + } + awaitItem().let { state -> + assertThat(state.query).isEqualTo("Search") + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.all( + RoomListFilter.NonLeft, + RoomListFilter.NormalizedMatchRoomName("Search") + ) + ) + state.eventSink(RoomListSearchEvents.ClearQuery) + } + awaitItem().let { state -> + assertThat(state.query).isEmpty() + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.all( + RoomListFilter.None, + ) + ) + } + } + } + + @Test + fun `present - room list changes`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListSearchPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.results).isEmpty() + } + roomListService.postAllRooms( + listOf( + RoomSummary.Empty("1"), + aRoomSummaryFilled() + ) + ) + awaitItem().let { state -> + assertThat(state.results).hasSize(1) + } + roomListService.postAllRooms(emptyList()) + awaitItem().let { state -> + assertThat(state.results).isEmpty() + } + } + } +} + +fun TestScope.createRoomListSearchPresenter( + roomListService: RoomListService = FakeRoomListService(), +): RoomListSearchPresenter { + return RoomListSearchPresenter( + roomListService = roomListService, + roomSummaryFactory = RoomListRoomSummaryFactory( + lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(), + roomLastMessageFormatter = FakeRoomLastMessageFormatter(), + ), + coroutineDispatchers = testCoroutineDispatchers(), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13e3cde9d8..2e11cc2f52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ compose_bom = "2024.02.00" composecompiler = "1.5.9" # Coroutines -coroutines = "1.7.3" +coroutines = "1.8.0" # Accompanist accompanist = "0.34.0" @@ -34,7 +34,7 @@ test_core = "1.5.0" coil = "2.5.0" datetime = "0.5.0" dependencyAnalysis = "1.30.0" -serialization_json = "1.6.2" +serialization_json = "1.6.3" showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.1" @@ -51,7 +51,7 @@ autoservice = "1.1.1" junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" -kover = "0.7.5" +kover = "0.7.6" [libraries] # Project @@ -132,7 +132,7 @@ test_junitext = "androidx.test.ext:junit:1.1.5" test_mockk = "io.mockk:mockk:1.13.9" test_konsist = "com.lemonappdev:konsist:0.13.0" test_turbine = "app.cash.turbine:turbine:1.0.0" -test_truth = "com.google.truth:truth:1.4.0" +test_truth = "com.google.truth:truth:1.4.1" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.15" test_robolectric = "org.robolectric:robolectric:4.11.1" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } @@ -163,7 +163,7 @@ sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0" -telephoto_zoomableimage = "me.saket.telephoto:zoomable-image-coil:0.7.1" +telephoto_zoomableimage = "me.saket.telephoto:zoomable-image-coil:0.8.0" statemachine = "com.freeletics.flowredux:compose:1.2.1" maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2" @@ -172,7 +172,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0" kotlinpoet = "com.squareup:kotlinpoet:1.16.0" # Analytics -posthog = "com.posthog:posthog-android:3.1.7" +posthog = "com.posthog:posthog-android:3.1.8" sentry = "io.sentry:sentry-android:7.3.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f" diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt index c7d9f2344f..1d50e77ce4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag @Composable fun FloatingActionButton( @@ -48,7 +50,7 @@ fun FloatingActionButton( ) { androidx.compose.material3.FloatingActionButton( onClick = onClick, - modifier = modifier, + modifier = modifier.testTag(TestTags.floatingActionButton), shape = shape, containerColor = containerColor, contentColor = contentColor, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 908390484b..646755ee39 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -27,7 +27,17 @@ data class RoomMember( val powerLevel: Long, val normalizedPowerLevel: Long, val isIgnored: Boolean, + val role: Role, ) { + /** + * Role of the RoomMember, based on its [powerLevel]. + */ + enum class Role { + ADMIN, + MODERATOR, + USER + } + /** * Disambiguated display name for the RoomMember. * If the display name is null, the user ID is returned. @@ -49,6 +59,10 @@ enum class RoomMembershipState { LEAVE } +/** + * Returns the best name value to display for the RoomMember. + * If the [RoomMember.displayName] is present and not empty it'll be used, otherwise the [RoomMember.userId] will be used. + */ fun RoomMember.getBestName(): String { return displayName?.takeIf { it.isNotEmpty() } ?: userId.value } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt index 5ffc58c332..0132e35092 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt @@ -28,11 +28,26 @@ import kotlin.time.Duration * Can be retrieved from [RoomListService] methods. */ interface RoomList { + /** + * The loading state of the room list. + */ sealed interface LoadingState { data object NotLoaded : LoadingState data class Loaded(val numberOfRooms: Int) : LoadingState } + /** + * The source of the room list data. + * All: all rooms except invites. + * Invites: only invites. + * + * To apply some dynamic filtering on top of that, use [DynamicRoomList]. + */ + enum class Source { + All, + Invites, + } + /** * The list of room summaries as a flow. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt index 99ba4531e2..41f5240a19 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt @@ -18,38 +18,68 @@ package io.element.android.libraries.matrix.api.roomlist sealed interface RoomListFilter { companion object { + /** + * Create a filter that matches all the given filters. + */ fun all(vararg filters: RoomListFilter): RoomListFilter { return All(filters.toList()) } + /** + * Create a filter that matches any of the given filters. + */ fun any(vararg filters: RoomListFilter): RoomListFilter { return Any(filters.toList()) } } + /** + * A filter that matches all the given filters. + */ data class All( val filters: List ) : RoomListFilter + /** + * A filter that matches any of the given filters. + */ data class Any( val filters: List ) : RoomListFilter + /** + * A filter that matches rooms that are not left. + */ data object NonLeft : RoomListFilter + /** + * A filter that matches rooms that are unread. + */ data object Unread : RoomListFilter + /** + * A filter that matches either Group or People rooms. + */ sealed interface Category : RoomListFilter { data object Group : Category data object People : Category } + /** + * A filter that matches no room. + */ data object None : RoomListFilter + /** + * A filter that matches rooms with a name using a normalized match. + */ data class NormalizedMatchRoomName( val pattern: String ) : RoomListFilter + /** + * A filter that matches rooms with a name using a fuzzy match. + */ data class FuzzyMatchRoomName( val pattern: String ) : RoomListFilter diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt index c13e6ecad9..04018b7bea 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.roomlist import androidx.compose.runtime.Immutable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow /** @@ -39,6 +40,20 @@ interface RoomListService { data object Hide : SyncIndicator } + /** + * Creates a room list that can be used to load more rooms and filter them dynamically. + * @param coroutineScope the scope to use for the room list. When the scope will be closed, the room list will be closed too. + * @param pageSize the number of rooms to load at once. + * @param initialFilter the initial filter to apply to the rooms. + * @param source the source of the rooms, either all rooms or invites. + */ + fun createRoomList( + coroutineScope: CoroutineScope, + pageSize: Int, + initialFilter: RoomListFilter, + source: RoomList.Source, + ): DynamicRoomList + /** * returns a [DynamicRoomList] object of all rooms we want to display. * This will exclude some rooms like the invites, or spaces. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 9880557e33..ef3a8a7b92 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -24,5 +24,5 @@ import kotlinx.parcelize.Parcelize data class MatrixUser( val userId: UserId, val displayName: String? = null, - val avatarUrl: String? = null + val avatarUrl: String? = null, ) : Parcelable 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 f7213becc4..a98596d227 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 @@ -197,10 +197,10 @@ class RustMatrixClient( RustRoomListService( innerRoomListService = innerRoomListService, sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, roomListFactory = RoomListFactory( innerRoomListService = innerRoomListService, - coroutineScope = sessionCoroutineScope, - dispatcher = sessionDispatcher, + sessionCoroutineScope = sessionCoroutineScope, ), ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt index e3d5b37cd4..9c38fca8a0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.room.member import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import uniffi.matrix_sdk.RoomMemberRole import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember @@ -33,9 +34,17 @@ object RoomMemberMapper { it.powerLevel(), it.normalizedPowerLevel(), it.isIgnored(), + mapRole(it.suggestedRoleForPowerLevel()) ) } + fun mapRole(role: RoomMemberRole): RoomMember.Role = + when (role) { + RoomMemberRole.ADMINISTRATOR -> RoomMember.Role.ADMIN + RoomMemberRole.MODERATOR -> RoomMember.Role.MODERATOR + RoomMemberRole.USER -> RoomMember.Role.USER + } + fun mapMembership(membershipState: RustMembershipState): RoomMembershipState = when (membershipState) { RustMembershipState.BAN -> RoomMembershipState.BAN 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 f0d9c7e2a4..421a1296a8 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 @@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList 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 kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,13 +29,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListService +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList -import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService internal class RoomListFactory( - private val innerRoomListService: InnerRoomListService, - private val coroutineScope: CoroutineScope, - private val dispatcher: CoroutineDispatcher, + private val innerRoomListService: RoomListService, + private val sessionCoroutineScope: CoroutineScope, private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), ) { /** @@ -44,18 +44,21 @@ internal class RoomListFactory( */ fun createRoomList( pageSize: Int, + coroutineScope: CoroutineScope = sessionCoroutineScope, + coroutineContext: CoroutineContext = EmptyCoroutineContext, initialFilter: RoomListFilter = RoomListFilter.all(), innerProvider: suspend () -> InnerRoomList ): DynamicRoomList { val loadingStateFlow: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) val summariesFlow = MutableStateFlow>(emptyList()) - val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, dispatcher, roomSummaryDetailsFactory) + val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryDetailsFactory) // Makes sure we don't miss any events val dynamicEvents = MutableSharedFlow(replay = 100) val currentFilter = MutableStateFlow(initialFilter) val loadedPages = MutableStateFlow(1) var innerRoomList: InnerRoomList? = null - coroutineScope.launch(dispatcher) { + + coroutineScope.launch(coroutineContext) { innerRoomList = innerProvider() innerRoomList?.let { innerRoomList -> innerRoomList.entriesFlow( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt index 5525d802bf..a5028bb614 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt @@ -17,7 +17,6 @@ package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -28,11 +27,12 @@ import org.matrix.rustcomponents.sdk.RoomListServiceInterface import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.util.UUID +import kotlin.coroutines.CoroutineContext class RoomSummaryListProcessor( private val roomSummaries: MutableStateFlow>, private val roomListService: RoomListServiceInterface, - private val dispatcher: CoroutineDispatcher, + private val coroutineContext: CoroutineContext, private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), ) { private val roomSummariesByIdentifier = HashMap() @@ -130,7 +130,7 @@ class RoomSummaryListProcessor( return builtRoomSummary } - private suspend fun updateRoomSummaries(block: suspend MutableList.() -> Unit) = withContext(dispatcher) { + private suspend fun updateRoomSummaries(block: suspend MutableList.() -> Unit) = withContext(coroutineContext) { mutex.withLock { val mutableRoomSummaries = roomSummaries.value.toMutableList() block(mutableRoomSummaries) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt index 4fef34b571..413c38be6e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt @@ -21,6 +21,7 @@ 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.RoomListService import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -42,10 +43,31 @@ private const val DEFAULT_PAGE_SIZE = 20 internal class RustRoomListService( private val innerRoomListService: InnerRustRoomListService, private val sessionCoroutineScope: CoroutineScope, - roomListFactory: RoomListFactory, + private val sessionDispatcher: CoroutineDispatcher, + private val roomListFactory: RoomListFactory, ) : RoomListService { + override fun createRoomList( + coroutineScope: CoroutineScope, + pageSize: Int, + initialFilter: RoomListFilter, + source: RoomList.Source + ): DynamicRoomList { + return roomListFactory.createRoomList( + pageSize = pageSize, + initialFilter = initialFilter, + coroutineScope = coroutineScope, + coroutineContext = sessionDispatcher, + ) { + when (source) { + RoomList.Source.All -> innerRoomListService.allRooms() + RoomList.Source.Invites -> innerRoomListService.invites() + } + } + } + override val allRooms: DynamicRoomList = roomListFactory.createRoomList( pageSize = DEFAULT_PAGE_SIZE, + coroutineContext = sessionDispatcher, initialFilter = RoomListFilter.all(RoomListFilter.NonLeft), ) { innerRoomListService.allRooms() @@ -53,6 +75,7 @@ internal class RustRoomListService( override val invites: RoomList = roomListFactory.createRoomList( pageSize = Int.MAX_VALUE, + coroutineContext = sessionDispatcher, ) { innerRoomListService.invites() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt index 1c153435db..4770f1b9e2 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt @@ -34,6 +34,7 @@ import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomMembersIterator +import uniffi.matrix_sdk.RoomMemberRole class RoomMemberListFetcherTest { @Test @@ -268,6 +269,7 @@ class FakeRustRoomMember( private val membership: MembershipState = MembershipState.JOIN, private val isNameAmbiguous: Boolean = false, private val powerLevel: Long = 0L, + private val role: RoomMemberRole = RoomMemberRole.USER, ) : RoomMember(NoPointer) { override fun userId(): String { return userId.value @@ -300,4 +302,8 @@ class FakeRustRoomMember( override fun isIgnored(): Boolean { return false } + + override fun suggestedRoleForPowerLevel(): RoomMemberRole { + return role + } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt index a9b0fea454..3812605546 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt @@ -158,7 +158,7 @@ class RoomSummaryListProcessorTests { private fun TestScope.createProcessor() = RoomSummaryListProcessor( summaries, fakeRoomListService, - dispatcher = StandardTestDispatcher(testScheduler), + coroutineContext = StandardTestDispatcher(testScheduler), roomSummaryDetailsFactory = RoomSummaryDetailsFactory(), ) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 945821f7f2..7593a5ba51 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -103,6 +103,10 @@ class FakeEncryptionService : EncryptionService { backupStateStateFlow.emit(state) } + suspend fun emitRecoveryState(state: RecoveryState) { + recoveryStateStateFlow.emit(state) + } + suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) { enableRecoveryProgressStateFlow.emit(state) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt index 8d43459aa8..76a1af8ac4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -29,6 +29,7 @@ fun aRoomMember( powerLevel: Long = 0L, normalizedPowerLevel: Long = 0L, isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.USER, ) = RoomMember( userId = userId, displayName = displayName, @@ -38,4 +39,5 @@ fun aRoomMember( powerLevel = powerLevel, normalizedPowerLevel = normalizedPowerLevel, isIgnored = isIgnored, + role = role, ) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index a1ef41b742..e0b0c38d4d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -53,6 +53,10 @@ fun aRoomSummaryFilled( ) ) +fun aRoomSummaryFilled( + details: RoomSummaryDetails = aRoomSummaryDetails(), +) = RoomSummary.Filled(details) + fun aRoomSummaryDetails( roomId: RoomId = A_ROOM_ID, name: String = A_ROOM_NAME, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt index 7540d6cee8..d53596e462 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt @@ -21,6 +21,7 @@ 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.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -59,13 +60,20 @@ class FakeRoomListService : RoomListService { var latestSlidingSyncRange: IntRange? = null private set - override val allRooms: DynamicRoomList = SimplePagedRoomList( + override fun createRoomList(coroutineScope: CoroutineScope, pageSize: Int, initialFilter: RoomListFilter, source: RoomList.Source): DynamicRoomList { + return when (source) { + RoomList.Source.All -> allRooms + RoomList.Source.Invites -> invites + } + } + + override val allRooms = SimplePagedRoomList( allRoomSummariesFlow, allRoomsLoadingStateFlow, MutableStateFlow(RoomListFilter.all()) ) - override val invites: RoomList = SimplePagedRoomList( + override val invites = SimplePagedRoomList( inviteRoomSummariesFlow, inviteRoomsLoadingStateFlow, MutableStateFlow(RoomListFilter.all()) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt index 4f1b07ce69..5ff9ed08bf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.getAndUpdate data class SimplePagedRoomList( - override val summaries: StateFlow>, + override val summaries: MutableStateFlow>, override val loadingState: StateFlow, override val currentFilter: MutableStateFlow ) : DynamicRoomList { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt new file mode 100644 index 0000000000..953127fc69 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.ui.room + +import io.element.android.libraries.matrix.api.room.RoomMember + +/** + * Returns the name value to use when sorting room members. + * + * If the display name is not null and not empty, it is returned. + * Otherwise, the user ID is returned without the initial "@". + */ +fun RoomMember.sortingName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value.drop(1) +} diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index ed93c19bc3..7992ddee17 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -74,4 +74,14 @@ object TestTags { val dialogPositive = TestTag("dialog-positive") val dialogNegative = TestTag("dialog-negative") val dialogNeutral = TestTag("dialog-neutral") + + /** + * Floating Action Button. + */ + val floatingActionButton = TestTag("floating-action-button") + + /** + * Timeline item. + */ + val timelineItemSenderInfo = TestTag("timeline_item-sender_info") } diff --git a/plugins/src/main/kotlin/extension/KoverExtension.kt b/plugins/src/main/kotlin/extension/KoverExtension.kt index 9185bfb1b0..20ee1f977f 100644 --- a/plugins/src/main/kotlin/extension/KoverExtension.kt +++ b/plugins/src/main/kotlin/extension/KoverExtension.kt @@ -85,6 +85,8 @@ fun Project.setupKover() { "*Presenter\$present\$*", // Forked from compose "io.element.android.libraries.designsystem.theme.components.bottomsheet.*", + // Test presenter + "io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter", ) annotatedBy( "androidx.compose.ui.tooling.preview.Preview", diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 218dfdd697..fe9d7d1202 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -30,6 +30,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore +import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter @@ -71,6 +72,21 @@ class RoomListScreen( private val featureFlagService = DefaultFeatureFlagService( providers = setOf(StaticFeatureFlagProvider()) ) + private val roomListRoomSummaryFactory = RoomListRoomSummaryFactory( + lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter( + localDateTimeProvider = dateTimeProvider, + dateFormatters = dateFormatters + ), + roomLastMessageFormatter = DefaultRoomLastMessageFormatter( + sp = stringProvider, + roomMembershipContentFormatter = RoomMembershipContentFormatter( + matrixClient = matrixClient, + sp = stringProvider + ), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider), + ), + ) private val presenter = RoomListPresenter( client = matrixClient, sessionVerificationService = sessionVerificationService, @@ -80,26 +96,11 @@ class RoomListScreen( leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers), roomListDataSource = RoomListDataSource( roomListService = matrixClient.roomListService, - roomListRoomSummaryFactory = RoomListRoomSummaryFactory( - lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter( - localDateTimeProvider = dateTimeProvider, - dateFormatters = dateFormatters - ), - roomLastMessageFormatter = DefaultRoomLastMessageFormatter( - sp = stringProvider, - roomMembershipContentFormatter = RoomMembershipContentFormatter( - matrixClient = matrixClient, - sp = stringProvider - ), - profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), - stateContentFormatter = StateContentFormatter(stringProvider), - ), - ), + roomListRoomSummaryFactory = roomListRoomSummaryFactory, coroutineDispatchers = coroutineDispatchers, notificationSettingsService = matrixClient.notificationSettingsService(), appScope = Singleton.appScope ), - encryptionService = encryptionService, indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, encryptionService = encryptionService, @@ -110,6 +111,11 @@ class RoomListScreen( matrixClient = matrixClient, migrationScreenStore = SharedPrefsMigrationScreenStore(context.getSharedPreferences("migration", Context.MODE_PRIVATE)) ), + searchPresenter = RoomListSearchPresenter( + roomListService = matrixClient.roomListService, + roomSummaryFactory = roomListRoomSummaryFactory, + coroutineDispatchers = coroutineDispatchers, + ), sessionPreferencesStore = DefaultSessionPreferencesStore( context = context, sessionId = matrixClient.sessionId, diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_0,NEXUS_5,1.0,en].png index 804123b945..217cb02ff2 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eba9e3f6b232813ccb2c42eb9ede3b594769619e4ce8f13b9d43c53688da070b -size 38958 +oid sha256:76bd5b2b4e4277cd9f87c8d39ec7490cde4d9de0f1f99cde8cd9061c19a9f34f +size 47105 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_6,NEXUS_5,1.0,en].png index 65e4968034..60a5a4dd2a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:839119e003cdbccea31c8a19ee50248890a3bc042a0f820163ce5fdd7411f193 -size 25282 +oid sha256:da396a8bdd3b9124c0b896e0dd79e7835091041b0544041421bf25b7f2edcaef +size 25960 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_0,NEXUS_5,1.0,en].png index 2a7f8a62ec..1a75ea22be 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:372179764b951cb9074a0e573241402ec2137045f26d9ffea1d567b93377c624 -size 38233 +oid sha256:02874e8bf08b040dc154b81e579c602e023abd99b2af8732a9e322921042ac8d +size 46597 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_6,NEXUS_5,1.0,en].png index cb1b703abc..ddf244e633 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ac113aa7a641c20d9c725078509de96e6878adf95e7ff456d77c309f263a16f -size 24936 +oid sha256:3f764f7ca578de87f854d4bd090bc642a851f4ce85deee91c0ca77e3d4a5f582 +size 25653 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null,NEXUS_5,1.0,en].png deleted file mode 100644 index adacc8d439..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b2dc223ad7ca556a74aaff1e0ec6a94c10b951457c7b9614804a4c477c4e6e8e -size 29974 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8a996b32af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f313e09baa93bb4ff9eda9a3126b2cf49867fac3c1d53f11ec027b4dad7fbff +size 4909 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5cc0279077 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Day-11_12_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1625ac34428f660c235d1d64f5de867baa6c0ca296f0a93d81588d633d6a74bf +size 30082 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 2f2649c98c..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:199f1cf052e17b08810d026921b53ee35b1a942f9b661beb2286e6891843898c -size 29867 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..205774d78d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e5a9aba28b5a7dfa41b8ca47e74ebe629f717d9bd51836d71c31adb436083af +size 4861 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f7230cc774 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl.search_RoomListSearchResultContent_null_RoomListSearchResultContent-Night-11_13_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:264d9373767b6b59d0cea4bfa4d148a453058be70e7805581fe96d7448ef5232 +size 29978 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_10,NEXUS_5,1.0,en].png index 4e360c7666..06846f3231 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_10,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 -size 89779 +oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2 +size 51948 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png index cdfb465771..f901e915e5 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79 -size 55617 +oid sha256:64c4eb481f40871925405ae317cb80927caf31cb552a8aa9549bfb5658ca91e4 +size 137589 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_12,NEXUS_5,1.0,en].png index 06846f3231..3057c59ef7 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_12,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_12,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2 -size 51948 +oid sha256:af42c9891a6670cdde07cf054e139ce1f83e877bb68f60463fcad6dd28d8e049 +size 6867 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_13,NEXUS_5,1.0,en].png deleted file mode 100644 index f901e915e5..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_13,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64c4eb481f40871925405ae317cb80927caf31cb552a8aa9549bfb5658ca91e4 -size 137589 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_6,NEXUS_5,1.0,en].png index 8a996b32af..2f2ac0e0c0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f313e09baa93bb4ff9eda9a3126b2cf49867fac3c1d53f11ec027b4dad7fbff -size 4909 +oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 +size 4462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_7,NEXUS_5,1.0,en].png index adacc8d439..2f2ac0e0c0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dc223ad7ca556a74aaff1e0ec6a94c10b951457c7b9614804a4c477c4e6e8e -size 29974 +oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 +size 4462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png index 2f2ac0e0c0..4e360c7666 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 -size 4462 +oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 +size 89779 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_9,NEXUS_5,1.0,en].png index 2f2ac0e0c0..cdfb465771 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_9,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_9,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 -size 4462 +oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79 +size 55617 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_10,NEXUS_5,1.0,en].png index a751592e9b..65b76802d2 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_10,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 -size 91394 +oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c +size 53590 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png index a7fd32602e..56f7f133f3 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d -size 57463 +oid sha256:a7bf47b0a25c455b108d7b3585a42849f03c6652af73a175fe2147cb1ad62a66 +size 161125 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_12,NEXUS_5,1.0,en].png index 65b76802d2..08cfca5c0b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_12,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_12,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c -size 53590 +oid sha256:6e95dac6e75d3f615ccf4d8e98f1780d8aeace1cddf0b33bba8b485860d3216b +size 6688 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_13,NEXUS_5,1.0,en].png deleted file mode 100644 index 56f7f133f3..0000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_13,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a7bf47b0a25c455b108d7b3585a42849f03c6652af73a175fe2147cb1ad62a66 -size 161125 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_6,NEXUS_5,1.0,en].png index 205774d78d..2f2ac0e0c0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e5a9aba28b5a7dfa41b8ca47e74ebe629f717d9bd51836d71c31adb436083af -size 4861 +oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 +size 4462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_7,NEXUS_5,1.0,en].png index 2f2649c98c..2f2ac0e0c0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:199f1cf052e17b08810d026921b53ee35b1a942f9b661beb2286e6891843898c -size 29867 +oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 +size 4462 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png index 2f2ac0e0c0..a751592e9b 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 -size 4462 +oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 +size 91394 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_9,NEXUS_5,1.0,en].png index 2f2ac0e0c0..a7fd32602e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_9,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_9,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0 -size 4462 +oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d +size 57463