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:
committed by
GitHub
parent
f0fcbb8ecd
commit
1670909408
1
changelog.d/2452.misc
Normal file
1
changelog.d/2452.misc
Normal file
@@ -0,0 +1 @@
|
||||
Improve room member list loading UX.
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user