Use `userId` on display name change state events

<!-- 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
- [x] Bugfix
- [ ] Technical
- [ ] Other :

## Content

- Replaces the first usage of the user's display name with its user id in display name change state events.

## Motivation and context

Partially fixes #2125 . It may not be the ideal solution, but until we decide how to handle this in both platforms, we should just follow what iOS currently does.

## Tests

<!-- Explain how you tested your development -->

- Change the display name of a user.
- Open a room that user has joined.

You should see a `{userId} changed their display name from {old} to {new}` state event.

## 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
- [ ] 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
- [ ] 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

Show new name just once, not twice in display name change events
### Your use case

#### What would you like to do?
See issue title.

#### Why would you like to do it?
It looks quite confusing at first glance, especially with long names that include sentences or `away` text:
![Screenshot_20230927-125549_Element X](https://github.com/element-hq/element-x-android/assets/46846000/256c2a29-06d0-40ae-b2e8-793db58f35ec)

#### How would you like to achieve it?
I think it would be sufficient to change the string like so: `$old_name changed their display name to $new_name`.

### Have you considered any alternatives?

I don't see anything better than simply showing `old -> new` instead of what's right now.

### Additional context

Maybe it'd be nicer for readability if the display names in that event were somehow highlighted, or just looked different than the `changed their display name` text, especially with long names like in screenshot above.

### Are you willing to provide a PR?

Yes

diff --git a/changelog.d/2125.bugfix b/changelog.d/2125.bugfix
new file mode 100644
index 00000000000..95e6fb769d0
--- /dev/null
+++ b/changelog.d/2125.bugfix
@@ -0,0 +1,1 @@
+Use the display name only once in display name change events. The user should be referenced by `userId` instead.
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index e9066681c56..cfc54e5ed1b 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -97,7 +97,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
                 roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
             }
             is ProfileChangeContent -> {
-                profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
+                profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
             }
             is StateContent -> {
                 stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
index e147f25e166..efd91270d39 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt
@@ -54,7 +54,7 @@ class DefaultTimelineEventFormatter @Inject constructor(
                 roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
             }
             is ProfileChangeContent -> {
-                profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
+                profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
             }
             is StateContent -> {
                 stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline)
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
index aea43298f9e..1b27c4ec158 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt
@@ -16,6 +16,7 @@

 package io.element.android.libraries.eventformatter.impl

+import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
 import io.element.android.services.toolbox.api.strings.StringProvider
 import javax.inject.Inject
@@ -25,6 +26,7 @@ class ProfileChangeContentFormatter @Inject constructor(
 ) {
     fun format(
         profileChangeContent: ProfileChangeContent,
+        senderId: UserId,
         senderDisplayName: String,
         senderIsYou: Boolean,
     ): String? = profileChangeContent.run {
@@ -32,7 +34,7 @@ class ProfileChangeContentFormatter @Inject constructor(
         val avatarChanged = avatarUrl != prevAvatarUrl
         return when {
             avatarChanged && displayNameChanged -> {
-                val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou)
+                val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderId, senderDisplayName, senderIsYou)
                 val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too)
                 "$message\n$avatarChangedToo"
             }
@@ -41,19 +43,19 @@ class ProfileChangeContentFormatter @Inject constructor(
                     if (senderIsYou) {
                         sp.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName)
                     } else {
-                        sp.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName)
+                        sp.getString(R.string.state_event_display_name_changed_from, senderId.value, prevDisplayName, displayName)
                     }
                 } else if (displayName != null) {
                     if (senderIsYou) {
                         sp.getString(R.string.state_event_display_name_set_by_you, displayName)
                     } else {
-                        sp.getString(R.string.state_event_display_name_set, senderDisplayName, displayName)
+                        sp.getString(R.string.state_event_display_name_set, senderId.value, displayName)
                     }
                 } else {
                     if (senderIsYou) {
                         sp.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName)
                     } else {
-                        sp.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName)
+                        sp.getString(R.string.state_event_display_name_removed, senderId.value, prevDisplayName)
                     }
                 }
             }

diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index b127911cc06..71860937b48 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -733,7 +733,7 @@ class DefaultRoomLastMessageFormatterTest {

         val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent)
         val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent, false)
-        assertThat(someoneChangedDisplayName).isEqualTo("$otherName changed their display name from $oldDisplayName to $newDisplayName")
+        assertThat(someoneChangedDisplayName).isEqualTo("$someoneElseId changed their display name from $oldDisplayName to $newDisplayName")

         val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent)
         val youSetDisplayName = formatter.format(youSetDisplayNameEvent, false)
@@ -741,7 +741,7 @@ class DefaultRoomLastMessageFormatterTest {

         val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent)
         val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent, false)
-        assertThat(someoneSetDisplayName).isEqualTo("$otherName set their display name to $newDisplayName")
+        assertThat(someoneSetDisplayName).isEqualTo("$someoneElseId set their display name to $newDisplayName")

         val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent)
         val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent, false)
@@ -749,7 +749,7 @@ class DefaultRoomLastMessageFormatterTest {

         val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent)
         val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent, false)
-        assertThat(someoneRemovedDisplayName).isEqualTo("$otherName removed their display name (it was $oldDisplayName)")
+        assertThat(someoneRemovedDisplayName).isEqualTo("$someoneElseId removed their display name (it was $oldDisplayName)")

         val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent)
         val unchangedResult = formatter.format(unchangedEvent, false)
@@ -828,7 +828,7 @@ class DefaultRoomLastMessageFormatterTest {
     // endregion

     private fun createRoomEvent(sentByYou: Boolean, senderDisplayName: String?, content: EventContent): EventTimelineItem {
-        val sender = if (sentByYou) A_USER_ID else UserId("@someone_else:domain")
+        val sender = if (sentByYou) A_USER_ID else someoneElseId
         val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null)
         return anEventTimelineItem(
             content = content,
@@ -837,4 +837,6 @@ class DefaultRoomLastMessageFormatterTest {
             isOwn = sentByYou,
         )
     }
+
+    private val someoneElseId = UserId("@someone_else:domain")
 }


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

Improve room member list loading

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

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())
 }

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