Repository: element-hq/element-x-android (language: kotlin)

Issue:
Improve room member list loading
### Your use case

#### What would you like to do?

Improve how the room member list is loaded for large rooms.

#### Why would you like to do it?

After the latest changes to improve the member list loading performance we don't need to chunk the member list to process it anymore. This should solve some UX issues we've had for a long time, but were better than having to wait ages to load the member list.

#### How would you like to achieve it?

By changing how the flow works:

1. No chunking for displaying the items. Once we have the whole list we display them all at the same time, which should take care of most of the UX weirdness.
2. Use a linear progress indicator to differentiate between the cached room member list being loaded and still needing to load the updated member list from the server, and having loaded the updated one.

### Have you considered any alternatives?

Yes:

1. Leaving it as is, which seems like the wrong call.
2. Use a circular indicator either in the center of the list as before, which would make the list not visible while the updated room list is loading, or adding the progress indicator to the top app bar, which seems like weird UX in material.

### Additional context

_No response_

### Are you willing to provide a PR?

Yes


Discussion hints:
nan

Pull request: Improve room member list loading UX
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Type of change

- [ ] Feature
- [ ] Bugfix
- [x] Technical
- [ ] Other :

## Content

- Don't display the list in chunks anymore.
- Use an indeterminate linear progress indicator to display some loading is being done (either loading the cached list or the updated one).
- Try to make sure we don't display the members loaded from timeline items as the cached room list by mistake.
- When a membership change event is received, reload the *cached* member list from the SDK, don't trigger a new update from the server. This should prevent loading MBs of data for large rooms when it's not needed.

## Motivation and context

Closes #2542.

## Screenshots / GIFs

In the PR.

## Tests

<!-- Explain how you tested your development -->

- Join Matrix HQ.
- Open its details, then 'People'.
- The member list should load a lot faster: first the cached items (you'll know they're cached because the progress bar will be displayed), then the updated ones.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): 14

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [x] Pull request is based on the develop branch
- [x] Pull request includes a new file under ./changelog.d. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [x] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [x] You've made a self review of your PR


Test command: ./gradlew :libraries:matrix:impl:testDebugUnitTest --tests "io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcherTest" :features:roomdetails:impl:testDebugUnitTest --tests "io.element.android.features.roomdetails.members.RoomMemberListPresenterTests"

Fix patch:
diff --git a/changelog.d/2452.misc b/changelog.d/2452.misc
new file mode 100644
index 00000000000..0f16ae8f454
--- /dev/null
+++ b/changelog.d/2452.misc
@@ -0,0 +1,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 4c9cd327b28..d67f80d3c28 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<RoomMembers>>(AsyncData.Loading()) }
+        var roomMembers by remember { mutableStateOf(RoomMembers.loading()) }
         var searchQuery by rememberSaveable { mutableStateOf("") }
         var searchResults by remember {
             mutableStateOf<SearchBarResultState<RoomMembers>>(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 368f0990579..67a0802f02d 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<RoomMembers>,
+    val roomMembers: RoomMembers,
     val searchQuery: String,
     val searchResults: SearchBarResultState<RoomMembers>,
     val isSearchActive: Boolean,
@@ -36,4 +36,14 @@ data class RoomMembers(
     val invited: ImmutableList<RoomMember>,
     val joined: ImmutableList<RoomMember>,
     val banned: ImmutableList<RoomMember>,
-)
+    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 062a2f7d83a..8be4124e0d6 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<RoomMember
     override val values: Sequence<RoomMemberListState>
         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<RoomMember
                         invited = persistentListOf(aVictor()),
                         joined = persistentListOf(anAlice()),
                         banned = persistentListOf(),
+                        isLoading = false,
                     )
                 ),
             ),
@@ -66,26 +65,37 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<Room
     override val values: Sequence<RoomMemberListState>
         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<Room
 }
 
 internal fun aRoomMemberListState(
-    roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized,
+    roomMembers: RoomMembers = RoomMembers.loading(),
     searchResults: SearchBarResultState<RoomMembers> = 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 70b3a11babf..029d541039b 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/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 9a85f25502e..9a1366a73e2 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<String?> = 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 e9b420273e9..f3b6c15b5fe 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<MatrixRoomMembersState>.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 MutableStateFlow<MatrixRoomMembersState>.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) {
-        roomMembersIterator.use { iterator ->
-            val results = buildList {
+    private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator): ImmutableList<RoomMember> {
+        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())
 }


Test patch:
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 e70483356ef..bc7dff92b11 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/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 93dbc4e948d..c6eacb66d3b 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 e4ecb034160..c80c0a10b5b 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 00000000000..e4ecb034160
--- /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 7496ef1eae6..e0b585926ce 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 00000000000..7496ef1eae6
--- /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 13aaa496295..ef753f6f1d5 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 8543d784df7..c8223eb399e 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 01b41e1eecc..ef753f6f1d5 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 83a97f3767a..0ce024ba267 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 aecee4c4048..7b6a496bf79 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 f86e5348a55..0ce024ba267 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