diff --git a/changelog.d/2452.misc b/changelog.d/2452.misc new file mode 100644 index 0000000000..0f16ae8f45 --- /dev/null +++ b/changelog.d/2452.misc @@ -0,0 +1 @@ +Improve room member list loading UX. 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 4c9cd327b2..d67f80d3c2 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 @@ -31,7 +31,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.theme.components.SearchBarResultState @@ -43,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.roomMembers import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -62,7 +62,7 @@ class RoomMemberListPresenter @AssistedInject constructor( @Composable override fun present(): RoomMemberListState { val coroutineScope = rememberCoroutineScope() - var roomMembers by remember { mutableStateOf>(AsyncData.Loading()) } + var roomMembers by remember { mutableStateOf(RoomMembers.loading()) } var searchQuery by rememberSaveable { mutableStateOf("") } var searchResults by remember { mutableStateOf>(SearchBarResultState.Initial()) @@ -90,21 +90,26 @@ class RoomMemberListPresenter @AssistedInject constructor( } withContext(coroutineDispatchers.io) { val members = membersState.roomMembers().orEmpty().groupBy { it.membership } - roomMembers = AsyncData.Success( - RoomMembers( - invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), - joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) - .sortedWith(PowerLevelRoomMemberComparator()) - .toImmutableList(), - banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(), - ) + val info = room.roomInfoFlow.first() + if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) { + // Don't display initial room member list if we have less than half of the joined members: + // This result will come from the timeline loading membership events and it'll be wrong. + return@withContext + } + roomMembers = RoomMembers( + invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), + joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) + .sortedWith(PowerLevelRoomMemberComparator()) + .toImmutableList(), + banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(), + isLoading = membersState is MatrixRoomMembersState.Pending, ) } } - LaunchedEffect(searchQuery) { + LaunchedEffect(membersState, searchQuery, isSearchActive) { withContext(coroutineDispatchers.io) { - searchResults = if (searchQuery.isEmpty()) { + searchResults = if (searchQuery.isEmpty() || !isSearchActive) { SearchBarResultState.Initial() } else { val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership } @@ -118,6 +123,7 @@ class RoomMemberListPresenter @AssistedInject constructor( .sortedWith(PowerLevelRoomMemberComparator()) .toImmutableList(), banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(), + isLoading = membersState is MatrixRoomMembersState.Pending, ) ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index 368f099057..67a0802f02 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -17,13 +17,13 @@ package io.element.android.features.roomdetails.impl.members import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.room.RoomMember import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf data class RoomMemberListState( - val roomMembers: AsyncData, + val roomMembers: RoomMembers, val searchQuery: String, val searchResults: SearchBarResultState, val isSearchActive: Boolean, @@ -36,4 +36,14 @@ data class RoomMembers( val invited: ImmutableList, val joined: ImmutableList, val banned: ImmutableList, -) + val isLoading: Boolean, +) { + companion object { + fun loading() = RoomMembers( + invited = persistentListOf(), + joined = persistentListOf(), + banned = persistentListOf(), + isLoading = true, + ) + } +} 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 062a2f7d83..8be4124e0d 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 @@ -19,7 +19,6 @@ package io.element.android.features.roomdetails.impl.members import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember @@ -30,15 +29,14 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider get() = sequenceOf( aRoomMemberListState( - roomMembers = AsyncData.Success( - RoomMembers( - invited = persistentListOf(aVictor(), aWalter()), - joined = persistentListOf(anAlice(), aBob(), aWalter()), - banned = persistentListOf(), - ) + roomMembers = RoomMembers( + invited = persistentListOf(aVictor(), aWalter()), + joined = persistentListOf(anAlice(), aBob(), aWalter()), + banned = persistentListOf(), + isLoading = false, ) ), - aRoomMemberListState(roomMembers = AsyncData.Loading()), + aRoomMemberListState(roomMembers = RoomMembers.loading()), aRoomMemberListState().copy(canInvite = true), aRoomMemberListState().copy(isSearchActive = false), aRoomMemberListState().copy(isSearchActive = true), @@ -51,6 +49,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider get() = sequenceOf( aRoomMemberListState( - roomMembers = AsyncData.Success( - RoomMembers( - invited = persistentListOf(), - joined = persistentListOf(), - banned = persistentListOf( - aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"), - aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"), - aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"), - ), - ) + roomMembers = RoomMembers( + invited = persistentListOf(), + joined = persistentListOf(), + banned = persistentListOf( + aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"), + aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"), + aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"), + ), + isLoading = false, ), moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), ), aRoomMemberListState( - roomMembers = AsyncData.Success( - RoomMembers( - invited = persistentListOf(), - joined = persistentListOf(), - banned = persistentListOf(), - ) + roomMembers = RoomMembers( + invited = persistentListOf(), + joined = persistentListOf(), + banned = persistentListOf( + aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"), + aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"), + aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"), + ), + isLoading = true, + ), + moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), + ), + aRoomMemberListState( + roomMembers = RoomMembers( + invited = persistentListOf(), + joined = persistentListOf(), + banned = persistentListOf(), + isLoading = false, ), moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true), ) @@ -93,7 +103,7 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider = AsyncData.Uninitialized, + roomMembers: RoomMembers = RoomMembers.loading(), searchResults: SearchBarResultState = SearchBarResultState.Initial(), moderationState: RoomMembersModerationState = aRoomMembersModerationState(), ) = RoomMemberListState( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index 70b3a11bab..029d541039 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -16,6 +16,11 @@ package io.element.android.features.roomdetails.impl.members +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -23,7 +28,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -49,13 +53,12 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.aliasScreenTitle -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState @@ -124,20 +127,15 @@ fun RoomMemberListView( ) if (!state.isSearchActive) { - if (state.roomMembers is AsyncData.Success) { - RoomMemberList( - roomMembers = state.roomMembers.data, - showMembersCount = true, - canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers, - selectedSection = selectedSection, - onSelectedSectionChanged = { selectedSection = it }, - onUserSelected = ::onUserSelected, - ) - } else if (state.roomMembers.isLoading()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } + RoomMemberList( + isLoading = state.roomMembers.isLoading, + roomMembers = state.roomMembers, + showMembersCount = true, + canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers, + selectedSection = selectedSection, + onSelectedSectionChanged = { selectedSection = it }, + onUserSelected = ::onUserSelected, + ) } } } @@ -151,6 +149,7 @@ fun RoomMemberListView( @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun RoomMemberList( + isLoading: Boolean, roomMembers: RoomMembers, showMembersCount: Boolean, selectedSection: SelectedSection, @@ -159,28 +158,37 @@ private fun RoomMemberList( onUserSelected: (RoomMember) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { - if (canDisplayBannedUsersControls) { - stickyHeader { - val segmentedButtonTitles = persistentListOf( - stringResource(id = R.string.screen_room_member_list_mode_members), - stringResource(id = R.string.screen_room_member_list_mode_banned), - ) - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .background(ElementTheme.colors.bgCanvasDefault) - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - ) { - for ((index, title) in segmentedButtonTitles.withIndex()) { - SegmentedButton( - index = index, - count = segmentedButtonTitles.size, - selected = selectedSection.ordinal == index, - onClick = { onSelectedSectionChanged(SelectedSection.entries[index]) }, - text = title, - ) + stickyHeader { + Column { + if (canDisplayBannedUsersControls) { + val segmentedButtonTitles = persistentListOf( + stringResource(id = R.string.screen_room_member_list_mode_members), + stringResource(id = R.string.screen_room_member_list_mode_banned), + ) + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .background(ElementTheme.colors.bgCanvasDefault) + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + for ((index, title) in segmentedButtonTitles.withIndex()) { + SegmentedButton( + index = index, + count = segmentedButtonTitles.size, + selected = selectedSection.ordinal == index, + onClick = { onSelectedSectionChanged(SelectedSection.entries[index]) }, + text = title, + ) + } } } + AnimatedVisibility( + visible = isLoading, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } when (selectedSection) { @@ -335,6 +343,7 @@ private fun RoomMemberSearchBar( resultState = state, resultHandler = { results -> RoomMemberList( + isLoading = false, roomMembers = results, showMembersCount = false, onUserSelected = { onUserSelected(it) }, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt index e70483356e..bc7dff92b1 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt @@ -30,7 +30,6 @@ import io.element.android.features.roomdetails.impl.members.aWalter import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState import io.element.android.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -40,6 +39,7 @@ 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.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,25 +54,28 @@ class RoomMemberListPresenterTests { val warmUpRule = WarmUpRule() @Test - fun `search is done automatically on start, but is async`() = runTest { - val room = FakeMatrixRoom() + fun `member loading is done automatically on start, but is async`() = runTest { + val room = FakeMatrixRoom().apply { + // Needed to avoid discarding the loaded members as a partial and invalid result + givenRoomInfo(aRoomInfo(joinedMembersCount = 2)) + } val presenter = createPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.roomMembers).isInstanceOf(AsyncData.Loading::class.java) + assertThat(initialState.roomMembers.isLoading).isTrue() assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.isSearchActive).isFalse() room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList())) // Skip item while the new members state is processed skipItems(1) - val loadedState = awaitItem() - assertThat(loadedState.roomMembers).isInstanceOf(AsyncData.Success::class.java) - assertThat((loadedState.roomMembers as AsyncData.Success).data.invited).isEqualTo(listOf(aVictor(), aWalter())) - assertThat((loadedState.roomMembers as AsyncData.Success).data.joined).isNotEmpty() + val loadedMembersState = awaitItem() + assertThat(loadedMembersState.roomMembers.isLoading).isFalse() + assertThat(loadedMembersState.roomMembers.invited).isEqualTo(listOf(aVictor(), aWalter())) + assertThat(loadedMembersState.roomMembers.joined).isNotEmpty() } } @@ -85,6 +88,7 @@ class RoomMemberListPresenterTests { skipItems(1) val loadedState = awaitItem() loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) + skipItems(1) val searchActiveState = awaitItem() assertThat(searchActiveState.isSearchActive).isTrue() } @@ -101,6 +105,7 @@ class RoomMemberListPresenterTests { loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) val searchActiveState = awaitItem() searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something")) + skipItems(1) val searchQueryUpdatedState = awaitItem() assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something") val searchSearchResultDelivered = awaitItem() @@ -119,6 +124,7 @@ class RoomMemberListPresenterTests { loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) val searchActiveState = awaitItem() searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice")) + skipItems(1) val searchQueryUpdatedState = awaitItem() assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice") val searchSearchResultDelivered = awaitItem() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 9a85f25502..9a1366a73e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -162,7 +162,8 @@ class RustMatrixRoom( init { timeline.membershipChangeEventReceived - .onEach { roomMemberListFetcher.fetchRoomMembers() } + // The new events should already be in the SDK cache, no need to fetch them from the server + .onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) } .launchIn(roomCoroutineScope) } @@ -219,7 +220,15 @@ class RustMatrixRoom( override val activeMemberCount: Long get() = innerRoom.activeMembersCount().toLong() - override suspend fun updateMembers() = roomMemberListFetcher.fetchRoomMembers() + override suspend fun updateMembers() { + val useCache = membersStateFlow.value is MatrixRoomMembersState.Unknown + val source = if (useCache) { + RoomMemberListFetcher.Source.CACHE_AND_SERVER + } else { + RoomMemberListFetcher.Source.SERVER + } + roomMemberListFetcher.fetchRoomMembers(source = source) + } override suspend fun userDisplayName(userId: UserId): Result = withContext(roomDispatcher) { runCatching { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt index e9b420273e..f3b6c15b5f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt @@ -16,9 +16,10 @@ package io.element.android.libraries.matrix.impl.room.member -import io.element.android.libraries.core.coroutine.parallelMap 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.roomMembers +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ensureActive @@ -42,6 +43,11 @@ internal class RoomMemberListFetcher( private val dispatcher: CoroutineDispatcher, private val pageSize: Int = 10_000, ) { + enum class Source { + CACHE, + CACHE_AND_SERVER, + SERVER, + } private val updatedRoomMemberMutex = Mutex() private val roomId = room.id() @@ -51,73 +57,86 @@ internal class RoomMemberListFetcher( /** * Fetches the room members for the given room. * It will emit the cached members first, and then the updated members in batches of [pageSize] items, through [membersFlow]. - * @param withCache Whether to load the cached members first. Defaults to true. + * @param source Where we should load the members from. Defaults to [Source.CACHE_AND_SERVER]. */ - suspend fun fetchRoomMembers(withCache: Boolean = true) { + suspend fun fetchRoomMembers(source: Source = Source.CACHE_AND_SERVER) { if (updatedRoomMemberMutex.isLocked) { Timber.i("Room members are already being updated for room $roomId") return } updatedRoomMemberMutex.withLock { withContext(dispatcher) { - // Load cached members as fallback and to get faster results - if (withCache) { - if (_membersFlow.value !is MatrixRoomMembersState.Ready) { - fetchCachedRoomMembers() - } else { - Timber.i("Cached members not found for $roomId") + _membersFlow.run { + when (source) { + Source.CACHE -> { + fetchCachedRoomMembers(asPendingState = false) + } + Source.CACHE_AND_SERVER -> { + fetchCachedRoomMembers(asPendingState = true) + fetchRemoteRoomMembers() + } + Source.SERVER -> { + fetchRemoteRoomMembers() + } } } - - val prevRoomMembers = (_membersFlow.value as? MatrixRoomMembersState.Ready)?.roomMembers?.toImmutableList() - _membersFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = prevRoomMembers) - - try { - // Start loading new members - parseAndEmitMembers(room.members()) - } catch (exception: CancellationException) { - Timber.d("Cancelled loading updated members for room $roomId") - throw exception - } catch (exception: Exception) { - Timber.e(exception, "Failed to load updated members for room $roomId") - _membersFlow.value = MatrixRoomMembersState.Error(exception, prevRoomMembers) - } } } } - internal suspend fun fetchCachedRoomMembers() = withContext(dispatcher) { + private suspend fun MutableStateFlow.fetchCachedRoomMembers(asPendingState: Boolean = true) { Timber.i("Loading cached members for room $roomId") try { - val iterator = room.membersNoSync() - parseAndEmitMembers(iterator) + // Send current member list with pending state to notify the UI that we are loading new members + emit(pendingWithCurrentMembers()) + val members = parseAndEmitMembers(room.membersNoSync()) + val newState = if (asPendingState) { + MatrixRoomMembersState.Pending(prevRoomMembers = members) + } else { + MatrixRoomMembersState.Ready(members) + } + emit(newState) } catch (exception: CancellationException) { Timber.d("Cancelled loading cached members for room $roomId") throw exception } catch (exception: Exception) { Timber.e(exception, "Failed to load cached members for room $roomId") - _membersFlow.value = MatrixRoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList()) + emit(MatrixRoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList())) } } - private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator) { - roomMembersIterator.use { iterator -> - val results = buildList { + private suspend fun MutableStateFlow.fetchRemoteRoomMembers() { + try { + // Send current member list with pending state to notify the UI that we are loading new members + emit(pendingWithCurrentMembers()) + // Start loading new members + emit(MatrixRoomMembersState.Ready(parseAndEmitMembers(room.members()))) + } catch (exception: CancellationException) { + Timber.d("Cancelled loading updated members for room $roomId") + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load updated members for room $roomId") + emit(MatrixRoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList())) + } + } + + private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator): ImmutableList { + return roomMembersIterator.use { iterator -> + val results = buildList(capacity = roomMembersIterator.len().toInt()) { while (true) { // Loading the whole membersIterator as a stop-gap measure. // We should probably implement some sort of paging in the future. coroutineContext.ensureActive() val chunk = iterator.nextChunk(pageSize.toUInt()) // Load next chunk. If null (no more items), exit the loop - val members = chunk?.parallelMap(RoomMemberMapper::map) ?: break + val members = chunk?.map(RoomMemberMapper::map) ?: break addAll(members) - Timber.i("Emitting first $size members for room $roomId") - _membersFlow.value = MatrixRoomMembersState.Ready(toImmutableList()) + Timber.i("Loaded first $size members for room $roomId") } } - if (results.isEmpty()) { - _membersFlow.value = MatrixRoomMembersState.Ready(results.toImmutableList()) - } + results.toImmutableList() } } + + private fun pendingWithCurrentMembers() = MatrixRoomMembersState.Pending(_membersFlow.value.roomMembers().orEmpty().toImmutableList()) } 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 93dbc4e948..c6eacb66d3 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 @@ -21,6 +21,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.CACHE +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.CACHE_AND_SERVER +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.SERVER import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 @@ -38,7 +41,7 @@ import uniffi.matrix_sdk.RoomMemberRole class RoomMemberListFetcherTest { @Test - fun `fetchCachedRoomMembers - emits cached members, if any`() = runTest { + fun `fetchRoomMembers with CACHE source - emits cached members, if any`() = runTest { val room = FakeRustRoom(getMembersNoSync = { FakeRoomMembersIterator( listOf( @@ -53,44 +56,53 @@ class RoomMemberListFetcherTest { fetcher.membersFlow.test { assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) - fetcher.fetchCachedRoomMembers() + fetcher.fetchRoomMembers(source = CACHE) - val readyItem = awaitItem() - assertThat(readyItem).isInstanceOf(MatrixRoomMembersState.Ready::class.java) - assertThat((readyItem as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3) + // Loading state + assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) + + val cachedItemsState = awaitItem() + assertThat(cachedItemsState).isInstanceOf(MatrixRoomMembersState.Ready::class.java) + assertThat((cachedItemsState as? MatrixRoomMembersState.Ready)?.roomMembers).hasSize(3) + + // Assert only the 'no sync' method was called, so no new member sync happened + assertThat(room.membersNoSyncCallCount).isEqualTo(1) + assertThat(room.membersCallCount).isEqualTo(0) } } @Test - fun `fetchCachedRoomMembers - emits empty list, if no members exist`() = runTest { + fun `fetchRoomMembers with CACHE source - emits empty list, if no members exist`() = runTest { val room = FakeRustRoom(getMembersNoSync = { FakeRoomMembersIterator(emptyList()) }) val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) fetcher.membersFlow.test { - fetcher.fetchCachedRoomMembers() + fetcher.fetchRoomMembers(source = CACHE) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) - assertThat(awaitItem().roomMembers()).isEmpty() + assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) + assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers).isEmpty() } } @Test - fun `fetchCachedRoomMembers - emits Error on error found`() = runTest { + fun `fetchRoomMembers with CACHE source - emits Error on error found`() = runTest { val room = FakeRustRoom(getMembersNoSync = { error("Some unexpected issue") }) val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) fetcher.membersFlow.test { - fetcher.fetchCachedRoomMembers() + fetcher.fetchRoomMembers(source = CACHE) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) + assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Error::class.java) } } @Test - fun `fetchCachedRoomMembers - emits items using page size`() = runTest { + fun `fetchRoomMembers with CACHE source - emits all items at once`() = runTest { val room = FakeRustRoom(getMembersNoSync = { FakeRoomMembersIterator( listOf( @@ -103,18 +115,21 @@ class RoomMemberListFetcherTest { val fetcher = RoomMemberListFetcher(room, Dispatchers.Default, pageSize = 2) fetcher.membersFlow.test { - fetcher.fetchCachedRoomMembers() + fetcher.fetchRoomMembers(source = CACHE) + // Initial state assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) - assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(2) - assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3) + // Started loading cached members + assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) + // Finished loading cached members + assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers).hasSize(3) ensureAllEventsConsumed() } } @Test - fun `fetchRoomMembers - with 'withCache' set to false emits only new members, if any`() = runTest { + fun `fetchRoomMembers with SERVER source - emits only new members, if any`() = runTest { val room = FakeRustRoom(getMembers = { FakeRoomMembersIterator( listOf( @@ -127,21 +142,25 @@ class RoomMemberListFetcherTest { val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) fetcher.membersFlow.test { - fetcher.fetchRoomMembers(withCache = false) + fetcher.fetchRoomMembers(source = SERVER) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3) + + // Assert only the 'sync' method was called, so a new member sync happened + assertThat(room.membersNoSyncCallCount).isEqualTo(0) + assertThat(room.membersCallCount).isEqualTo(1) } } @Test - fun `fetchRoomMembers - on error it emits an Error item`() = runTest { + fun `fetchRoomMembers with SERVER source - on error it emits an Error item`() = runTest { val room = FakeRustRoom(getMembers = { error("An unexpected error") }) val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) fetcher.membersFlow.test { - fetcher.fetchRoomMembers(withCache = false) + fetcher.fetchRoomMembers(source = SERVER) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) @@ -150,7 +169,7 @@ class RoomMemberListFetcherTest { } @Test - fun `fetchRoomMembers - with 'withCache' returns cached items first, then new ones`() = runTest { + fun `fetchRoomMembers with CACHE_AND_SERVER source - returns cached items first, then new ones`() = runTest { val room = FakeRustRoom( getMembersNoSync = { FakeRoomMembersIterator(listOf(fakeRustRoomMember(A_USER_ID_4))) @@ -168,56 +187,28 @@ class RoomMemberListFetcherTest { val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) fetcher.membersFlow.test { - fetcher.fetchRoomMembers(withCache = true) + fetcher.fetchRoomMembers(source = CACHE_AND_SERVER) // Initial assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java) + // Loading cached + awaitItem().let { pending -> + assertThat(pending).isInstanceOf(MatrixRoomMembersState.Pending::class.java) + assertThat(pending.roomMembers()).isEmpty() + } // Loaded cached awaitItem().let { cached -> - assertThat(cached).isInstanceOf(MatrixRoomMembersState.Ready::class.java) + assertThat(cached).isInstanceOf(MatrixRoomMembersState.Pending::class.java) assertThat(cached.roomMembers()).hasSize(1) } // Start loading new - assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) awaitItem().let { ready -> assertThat(ready).isInstanceOf(MatrixRoomMembersState.Ready::class.java) assertThat(ready.roomMembers()).hasSize(3) } - } - } - @Test - fun `fetchRoomMembers - with 'withCache' skips cache if there is already a ready state`() = runTest { - val room = FakeRustRoom( - getMembersNoSync = { - FakeRoomMembersIterator(listOf(fakeRustRoomMember(A_USER_ID_4))) - }, - getMembers = { - FakeRoomMembersIterator( - listOf( - fakeRustRoomMember(A_USER_ID), - fakeRustRoomMember(A_USER_ID_2), - fakeRustRoomMember(A_USER_ID_3), - ) - ) - } - ) - - val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) - // Set a ready state - fetcher.fetchRoomMembers(withCache = false) - - fetcher.membersFlow.test { - // Start loading new members - fetcher.fetchRoomMembers(withCache = true) - // Previous ready state - assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Ready::class.java) - // New pending state - assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java) - // New ready state - awaitItem().let { ready -> - assertThat(ready).isInstanceOf(MatrixRoomMembersState.Ready::class.java) - assertThat(ready.roomMembers()).hasSize(3) - } + // Assert both member methods were called, so both the cache was hit and a new member sync happened + assertThat(room.membersNoSyncCallCount).isEqualTo(1) + assertThat(room.membersCallCount).isEqualTo(1) } } } @@ -226,15 +217,20 @@ class FakeRustRoom( private val getMembers: () -> RoomMembersIterator = { FakeRoomMembersIterator() }, private val getMembersNoSync: () -> RoomMembersIterator = { FakeRoomMembersIterator() }, ) : Room(NoPointer) { + var membersCallCount = 0 + var membersNoSyncCallCount = 0 + override fun id(): String { return A_ROOM_ID.value } override suspend fun members(): RoomMembersIterator { + membersCallCount++ return getMembers() } override suspend fun membersNoSync(): RoomMembersIterator { + membersNoSyncCallCount++ return getMembersNoSync() } diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_1,NEXUS_5,1.0,en].png index e4ecb03416..c80c0a10b5 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e02a3def9e4c0a791f0dbfbed39f2823545a6108e438fe955c43287bcb20c0d -size 24028 +oid sha256:6da4563d01a3e9edab952b234d25cb9e366407b1d5ca61410ac3019bd20c405d +size 35358 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4ecb03416 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Day-3_4_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e02a3def9e4c0a791f0dbfbed39f2823545a6108e438fe955c43287bcb20c0d +size 24028 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_1,NEXUS_5,1.0,en].png index 7496ef1eae..e0b585926c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41cf3bf61af52c2c7d71c17fe95f5217cf74a9c930a981131c6736a887edf50b -size 22699 +oid sha256:8a30602acc9e9dcfaae3d9245085194abf98db3095d9d407ee9dea35e750f93f +size 34359 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7496ef1eae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberBannedList_null_RoomMemberBannedList-Night-3_5_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41cf3bf61af52c2c7d71c17fe95f5217cf74a9c930a981131c6736a887edf50b +size 22699 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_1,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_1,NEXUS_5,1.0,en].png index 13aaa49629..ef753f6f1d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_1,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_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:048260d064b0868e1c26c37ff54e260abb2a6ace663061b4c73bc08e2feb030c -size 14874 +oid sha256:a9bd17eb1ac6b97db9f72851c81e1c7c45019df2d6c8a019c6f0438fd948e568 +size 13217 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_2,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_2,NEXUS_5,1.0,en].png index 8543d784df..c8223eb399 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_2,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_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32e6fb0b216777b77d68c2124550b9a94583986b2f288e52448458064cd4da4e -size 14176 +oid sha256:3691742bafd0ac9ec535e99dd733354c5d505c5de4f3d73af313cb01c04035f1 +size 14234 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_3,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_3,NEXUS_5,1.0,en].png index 01b41e1eec..ef753f6f1d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Day-2_3_null_3,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_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c07175544a9e9f498eb1c76507dd67505197fec30d200a7d27eb29bbe01e6fa -size 13158 +oid sha256:a9bd17eb1ac6b97db9f72851c81e1c7c45019df2d6c8a019c6f0438fd948e568 +size 13217 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_1,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_1,NEXUS_5,1.0,en].png index 83a97f3767..0ce024ba26 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_1,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_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3be57b648bb864bde868b85666b71f6d7fdc4da2fac0c2e3a1e31bd53f51ad12 -size 13960 +oid sha256:796772eea276c3e9beebd1d752c93b733ddc3aa03ea53dac2402b2255dfd69ab +size 12364 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_2,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_2,NEXUS_5,1.0,en].png index aecee4c404..7b6a496bf7 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_2,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_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32e56921168421f50abfbc2456907263b88de478e04c126419648b6939766c67 -size 13184 +oid sha256:02f80100d1e6fa388c04dba22842189489510969be3e1af13cc500226b8fde93 +size 13232 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_3,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_3,NEXUS_5,1.0,en].png index f86e5348a5..0ce024ba26 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.members_RoomMemberList_null_RoomMemberList-Night-2_4_null_3,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_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cab4067a59e8e0043df3b68b422279f34c2f072a4c4ebf2aa11ce92a26dd2ce -size 12315 +oid sha256:796772eea276c3e9beebd1d752c93b733ddc3aa03ea53dac2402b2255dfd69ab +size 12364