Improve room member list loading UX (#2543)

Improve room member list UX:

- 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.
* Update screenshots
* Simplify member loading logic.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-03-14 09:05:44 +01:00
committed by GitHub
parent f0fcbb8ecd
commit 1670909408
19 changed files with 268 additions and 196 deletions

1
changelog.d/2452.misc Normal file
View File

@@ -0,0 +1 @@
Improve room member list loading UX.

View File

@@ -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,
)
)
}

View File

@@ -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,
)
}
}

View File

@@ -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(

View File

@@ -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) },

View File

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

View File

@@ -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 {

View File

@@ -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 parseAndEmitMembers(roomMembersIterator: RoomMembersIterator) {
roomMembersIterator.use { iterator ->
val results = buildList {
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): 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())
}

View File

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