Add suggestions section to InvitePeopleView
This commit is contained in:
parent
23d3066a38
commit
7be66061e2
11 changed files with 227 additions and 48 deletions
|
|
@ -11,6 +11,7 @@ package io.element.android.features.invitepeople.impl
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
|
@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.filterMembers
|
import io.element.android.libraries.matrix.api.room.filterMembers
|
||||||
|
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.usersearch.api.UserRepository
|
import io.element.android.libraries.usersearch.api.UserRepository
|
||||||
|
|
@ -47,11 +49,16 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val MAX_SUGGESTIONS_COUNT = 5
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class DefaultInvitePeoplePresenter(
|
class DefaultInvitePeoplePresenter(
|
||||||
@Assisted private val joinedRoom: JoinedRoom?,
|
@Assisted private val joinedRoom: JoinedRoom?,
|
||||||
|
|
@ -78,6 +85,34 @@ class DefaultInvitePeoplePresenter(
|
||||||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||||
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
|
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
|
||||||
|
if (roomMembers.value.isSuccess()) {
|
||||||
|
val activeMemberIds = roomMembers.value.dataOrNull().orEmpty()
|
||||||
|
.filter { it.membership.isActive() }
|
||||||
|
.mapTo(mutableSetOf()) { it.userId }
|
||||||
|
|
||||||
|
value = matrixClient.getRecentDirectRooms()
|
||||||
|
.filterNot { it.matrixUser.userId in activeMemberIds }
|
||||||
|
.take(MAX_SUGGESTIONS_COUNT)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert recent direct rooms to InvitableUser for display
|
||||||
|
val suggestions by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
recentDirectRooms.map { recentDirectRoom ->
|
||||||
|
InvitableUser(
|
||||||
|
matrixUser = recentDirectRoom.matrixUser,
|
||||||
|
isSelected = recentDirectRoom.matrixUser in selectedUsers.value,
|
||||||
|
isAlreadyJoined = false,
|
||||||
|
isAlreadyInvited = false,
|
||||||
|
isUnresolved = false,
|
||||||
|
)
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
|
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
|
||||||
if (joinedRoom == null) {
|
if (joinedRoom == null) {
|
||||||
val result = matrixClient.getJoinedRoom(roomId)
|
val result = matrixClient.getJoinedRoom(roomId)
|
||||||
|
|
@ -118,6 +153,7 @@ class DefaultInvitePeoplePresenter(
|
||||||
is DefaultInvitePeopleEvents.ToggleUser -> {
|
is DefaultInvitePeopleEvents.ToggleUser -> {
|
||||||
selectedUsers.toggleUser(event.user)
|
selectedUsers.toggleUser(event.user)
|
||||||
searchResults.toggleUser(event.user)
|
searchResults.toggleUser(event.user)
|
||||||
|
// suggestions will automatically update via derivedStateOf when selectedUsers changes
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.SendInvites -> {
|
is InvitePeopleEvents.SendInvites -> {
|
||||||
room.dataOrNull()?.let {
|
room.dataOrNull()?.let {
|
||||||
|
|
@ -140,6 +176,7 @@ class DefaultInvitePeoplePresenter(
|
||||||
searchResults = searchResults.value,
|
searchResults = searchResults.value,
|
||||||
showSearchLoader = showSearchLoader.value,
|
showSearchLoader = showSearchLoader.value,
|
||||||
sendInvitesAction = sendInvitesAction.value,
|
sendInvitesAction = sendInvitesAction.value,
|
||||||
|
suggestions = suggestions,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,6 @@ data class DefaultInvitePeopleState(
|
||||||
val selectedUsers: ImmutableList<MatrixUser>,
|
val selectedUsers: ImmutableList<MatrixUser>,
|
||||||
override val isSearchActive: Boolean,
|
override val isSearchActive: Boolean,
|
||||||
override val sendInvitesAction: AsyncAction<Unit>,
|
override val sendInvitesAction: AsyncAction<Unit>,
|
||||||
|
val suggestions: ImmutableList<InvitableUser>,
|
||||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
override val eventSink: (InvitePeopleEvents) -> Unit
|
||||||
) : InvitePeopleState
|
) : InvitePeopleState
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ private fun aDefaultInvitePeopleState(
|
||||||
isSearchActive: Boolean = false,
|
isSearchActive: Boolean = false,
|
||||||
showSearchLoader: Boolean = false,
|
showSearchLoader: Boolean = false,
|
||||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
suggestions: List<InvitableUser> = aMatrixUserList()
|
||||||
|
.take(5)
|
||||||
|
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
|
||||||
): DefaultInvitePeopleState {
|
): DefaultInvitePeopleState {
|
||||||
return DefaultInvitePeopleState(
|
return DefaultInvitePeopleState(
|
||||||
room = room,
|
room = room,
|
||||||
|
|
@ -111,6 +114,7 @@ private fun aDefaultInvitePeopleState(
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
showSearchLoader = showSearchLoader,
|
showSearchLoader = showSearchLoader,
|
||||||
sendInvitesAction = sendInvitesAction,
|
sendInvitesAction = sendInvitesAction,
|
||||||
|
suggestions = suggestions.toImmutableList(),
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -82,6 +84,10 @@ private fun InvitePeopleContentView(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
fun toggleUser(user: MatrixUser) {
|
||||||
|
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(user))
|
||||||
|
}
|
||||||
|
|
||||||
InvitePeopleSearchBar(
|
InvitePeopleSearchBar(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
|
|
@ -97,17 +103,45 @@ private fun InvitePeopleContentView(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
|
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
|
||||||
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
|
onToggleUser = ::toggleUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!state.isSearchActive) {
|
if (!state.isSearchActive) {
|
||||||
SelectedUsersRowList(
|
if (state.selectedUsers.isNotEmpty()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
SelectedUsersRowList(
|
||||||
selectedUsers = state.selectedUsers,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
autoScroll = true,
|
selectedUsers = state.selectedUsers,
|
||||||
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
|
autoScroll = true,
|
||||||
contentPadding = PaddingValues(16.dp),
|
onUserRemove = ::toggleUser,
|
||||||
)
|
contentPadding = PaddingValues(all = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.suggestions.isNotEmpty()) {
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
ListSectionHeader(
|
||||||
|
title = stringResource(id = CommonStrings.common_suggestions),
|
||||||
|
hasDivider = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
itemsIndexed(state.suggestions) { index, invitableUser ->
|
||||||
|
CheckableUserRow(
|
||||||
|
checked = invitableUser.isSelected,
|
||||||
|
onCheckedChange = {
|
||||||
|
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(invitableUser.matrixUser))
|
||||||
|
},
|
||||||
|
data = CheckableUserRowData.Resolved(
|
||||||
|
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
|
||||||
|
name = invitableUser.matrixUser.getBestName(),
|
||||||
|
subtext = invitableUser.matrixUser.userId.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (index < state.suggestions.lastIndex) {
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +174,7 @@ private fun InvitePeopleSearchBar(
|
||||||
selectedUsers = selectedUsers,
|
selectedUsers = selectedUsers,
|
||||||
autoScroll = true,
|
autoScroll = true,
|
||||||
onUserRemove = onToggleUser,
|
onUserRemove = onToggleUser,
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(all = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
|
@ -26,7 +27,9 @@ 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
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMemberList
|
import io.element.android.libraries.matrix.test.room.aRoomMemberList
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
|
@ -67,13 +70,15 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
assertThat(initialState.canInvite).isFalse()
|
assertThat(initialState.canInvite).isFalse()
|
||||||
assertThat(initialState.searchQuery).isEmpty()
|
assertThat(initialState.searchQuery).isEmpty()
|
||||||
|
|
||||||
skipItems(1)
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - updates search active state`() = runTest {
|
fun `present - updates search active state`() = runTest {
|
||||||
val presenter = createDefaultInvitePeoplePresenter()
|
val presenter = createDefaultInvitePeoplePresenter(
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -85,11 +90,12 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
|
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
|
||||||
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query")
|
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query")
|
||||||
resultState.eventSink(InvitePeopleEvents.CloseSearch)
|
resultState.eventSink(InvitePeopleEvents.CloseSearch)
|
||||||
skipItems(1)
|
skipItems(2)
|
||||||
awaitItemAsDefault().also {
|
awaitItemAsDefault().also {
|
||||||
assertThat(it.isSearchActive).isFalse()
|
assertThat(it.isSearchActive).isFalse()
|
||||||
assertThat(it.searchQuery).isEmpty()
|
assertThat(it.searchQuery).isEmpty()
|
||||||
}
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,7 +281,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
val repository = FakeUserRepository()
|
val repository = FakeUserRepository()
|
||||||
val presenter = createDefaultInvitePeoplePresenter(
|
val presenter = createDefaultInvitePeoplePresenter(
|
||||||
userRepository = repository,
|
userRepository = repository,
|
||||||
coroutineDispatchers = testCoroutineDispatchers()
|
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
@ -519,6 +525,85 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - suggestions are loaded from recent direct rooms`() = runTest {
|
||||||
|
val dmRoomId = RoomId("!dm_room:server.org")
|
||||||
|
val otherUserId = UserId("@frank:server.org")
|
||||||
|
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
|
||||||
|
// Track the DM room as recently visited
|
||||||
|
trackRecentlyVisitedRoom(dmRoomId)
|
||||||
|
// Set up a DM room with the other user
|
||||||
|
givenGetRoomResult(
|
||||||
|
dmRoomId,
|
||||||
|
FakeBaseRoom(
|
||||||
|
sessionId = A_USER_ID,
|
||||||
|
roomId = dmRoomId,
|
||||||
|
initialRoomInfo = aRoomInfo(
|
||||||
|
id = dmRoomId,
|
||||||
|
isDirect = true,
|
||||||
|
activeMembersCount = 2,
|
||||||
|
currentUserMembership = CurrentUserMembership.JOINED,
|
||||||
|
),
|
||||||
|
getDirectRoomMemberResult = { aRoomMember(userId = otherUserId, displayName = "Frank") }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val presenter = createDefaultInvitePeoplePresenter(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
// Use empty room members so the suggestion doesn't get filtered
|
||||||
|
roomMembersState = RoomMembersState.Ready(persistentListOf()),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(2)
|
||||||
|
val state = awaitItemAsDefault()
|
||||||
|
assertThat(state.suggestions).hasSize(1)
|
||||||
|
assertThat(state.suggestions.first().matrixUser.userId).isEqualTo(otherUserId)
|
||||||
|
assertThat(state.suggestions.first().isSelected).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - suggestions filters out existing room members`() = runTest {
|
||||||
|
val dmRoomId = RoomId("!dm_room:server.org")
|
||||||
|
val alreadyJoinedUserId = UserId("@frank:server.org")
|
||||||
|
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
|
||||||
|
trackRecentlyVisitedRoom(dmRoomId)
|
||||||
|
givenGetRoomResult(
|
||||||
|
dmRoomId,
|
||||||
|
FakeBaseRoom(
|
||||||
|
sessionId = A_USER_ID,
|
||||||
|
roomId = dmRoomId,
|
||||||
|
initialRoomInfo = aRoomInfo(
|
||||||
|
id = dmRoomId,
|
||||||
|
isDirect = true,
|
||||||
|
activeMembersCount = 2,
|
||||||
|
currentUserMembership = CurrentUserMembership.JOINED,
|
||||||
|
),
|
||||||
|
getDirectRoomMemberResult = { aRoomMember(userId = alreadyJoinedUserId, displayName = "Frank") }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The user in the suggestion is already a member of the target room
|
||||||
|
val presenter = createDefaultInvitePeoplePresenter(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
roomMembersState = RoomMembersState.Ready(
|
||||||
|
persistentListOf(
|
||||||
|
aRoomMember(userId = alreadyJoinedUserId, membership = RoomMembershipState.JOIN)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
// The suggestion should be filtered out because the user is already a room member
|
||||||
|
val state = awaitItemAsDefault()
|
||||||
|
assertThat(state.suggestions).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun FakeUserRepository.emitStateWithUsers(
|
private suspend fun FakeUserRepository.emitStateWithUsers(
|
||||||
users: List<MatrixUser>,
|
users: List<MatrixUser>,
|
||||||
isSearching: Boolean = false
|
isSearching: Boolean = false
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
|
||||||
|
private const val MAX_SUGGESTIONS_COUNT = 5
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class DefaultUserListPresenter(
|
class DefaultUserListPresenter(
|
||||||
|
|
@ -53,7 +57,10 @@ class DefaultUserListPresenter(
|
||||||
override fun present(): UserListState {
|
override fun present(): UserListState {
|
||||||
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
|
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
recentDirectRooms = matrixClient
|
||||||
|
.getRecentDirectRooms()
|
||||||
|
.take(MAX_SUGGESTIONS_COUNT)
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,13 @@ interface BaseRoom : Closeable {
|
||||||
*/
|
*/
|
||||||
suspend fun getUpdatedMember(userId: UserId): Result<RoomMember>
|
suspend fun getUpdatedMember(userId: UserId): Result<RoomMember>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the direct room member, if any.
|
||||||
|
* This is a convenience method for getting the other member in a direct message room.
|
||||||
|
* Returns null if the room is not a dm or if the member cannot be found.
|
||||||
|
*/
|
||||||
|
suspend fun getDirectRoomMember(): RoomMember?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the room to the sync subscription list.
|
* Adds the room to the sync subscription list.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,7 @@ import kotlin.coroutines.CoroutineContext
|
||||||
* It does filter through the already known members, it doesn't perform additional requests.
|
* It does filter through the already known members, it doesn't perform additional requests.
|
||||||
*/
|
*/
|
||||||
suspend fun BaseRoom.filterMembers(query: String, coroutineContext: CoroutineContext): List<RoomMember> = withContext(coroutineContext) {
|
suspend fun BaseRoom.filterMembers(query: String, coroutineContext: CoroutineContext): List<RoomMember> = withContext(coroutineContext) {
|
||||||
val roomMembersState = membersStateFlow.value
|
val activeRoomMembers = membersStateFlow.value.activeRoomMembers()
|
||||||
val activeRoomMembers = roomMembersState.roomMembers()
|
|
||||||
?.filter { it.membership.isActive() }
|
|
||||||
.orEmpty()
|
|
||||||
val filteredMembers = if (query.isBlank()) {
|
val filteredMembers = if (query.isBlank()) {
|
||||||
activeRoomMembers
|
activeRoomMembers
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -11,48 +11,35 @@ package io.element.android.libraries.matrix.api.room.recent
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
import io.element.android.libraries.matrix.api.room.isDm
|
||||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5
|
|
||||||
|
|
||||||
data class RecentDirectRoom(
|
data class RecentDirectRoom(
|
||||||
val roomId: RoomId,
|
val roomId: RoomId,
|
||||||
val matrixUser: MatrixUser,
|
val matrixUser: MatrixUser,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun MatrixClient.getRecentDirectRooms(
|
/**
|
||||||
maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN,
|
* Returns a [Flow] of [RecentDirectRoom] from recently visited DM rooms.
|
||||||
): List<RecentDirectRoom> {
|
* The flow emits items lazily, allowing callers to filter and take only what they need.
|
||||||
val result = mutableListOf<RecentDirectRoom>()
|
* Use [kotlinx.coroutines.flow.take] to limit results and stop iteration early.
|
||||||
|
*/
|
||||||
|
fun MatrixClient.getRecentDirectRooms(): Flow<RecentDirectRoom> = flow {
|
||||||
val foundUserIds = mutableSetOf<UserId>()
|
val foundUserIds = mutableSetOf<UserId>()
|
||||||
getRecentlyVisitedRooms().getOrNull()?.let { roomIds ->
|
val recentlyVisitedRooms = getRecentlyVisitedRooms().getOrDefault(emptyList())
|
||||||
roomIds
|
for (roomId in recentlyVisitedRooms) {
|
||||||
.mapNotNull { roomId -> getRoom(roomId) }
|
getRoom(roomId)?.use { room ->
|
||||||
.filter { it.isDm() && it.isJoined() }
|
val info = room.info()
|
||||||
.map { room ->
|
if (info.isDm && info.currentUserMembership == CurrentUserMembership.JOINED) {
|
||||||
val otherUser = room.getMembers().getOrNull()
|
val otherUser = room.getDirectRoomMember()?.toMatrixUser()
|
||||||
?.firstOrNull { it.userId != sessionId }
|
if (otherUser != null && foundUserIds.add(otherUser.userId)) {
|
||||||
?.takeIf { foundUserIds.add(it.userId) }
|
emit(RecentDirectRoom(room.roomId, otherUser))
|
||||||
?.toMatrixUser()
|
|
||||||
if (otherUser != null) {
|
|
||||||
result.add(
|
|
||||||
RecentDirectRoom(room.roomId, otherUser)
|
|
||||||
)
|
|
||||||
// Return early to avoid useless computation
|
|
||||||
if (result.size >= maxNumberOfResults) {
|
|
||||||
return@map
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun BaseRoom.isJoined(): Boolean {
|
|
||||||
return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||||
|
import io.element.android.libraries.matrix.api.room.isDm
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||||
|
|
@ -112,6 +113,20 @@ class RustBaseRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getDirectRoomMember(): RoomMember? = withContext(roomDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
if (info().isDm) {
|
||||||
|
innerRoom.membersNoSync().use { members ->
|
||||||
|
members.nextChunk(members.len())
|
||||||
|
?.map(RoomMemberMapper::map)
|
||||||
|
?.firstOrNull { roomMember -> roomMember.userId != sessionId && roomMember.membership.isActive() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getUpdatedMember(userId: UserId): Result<RoomMember> = withContext(roomDispatcher) {
|
override suspend fun getUpdatedMember(userId: UserId): Result<RoomMember> = withContext(roomDispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
RoomMemberMapper.map(innerRoom.member(userId.value))
|
RoomMemberMapper.map(innerRoom.member(userId.value))
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ class FakeBaseRoom(
|
||||||
private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() },
|
private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
private var updateMembersResult: () -> Unit = { lambdaError() },
|
private var updateMembersResult: () -> Unit = { lambdaError() },
|
||||||
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
|
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
|
||||||
|
private val getDirectRoomMemberResult: () -> RoomMember? = { null },
|
||||||
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
|
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
|
||||||
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
|
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
|
||||||
private val clearComposerDraftLambda: () -> Result<Unit> = { Result.success(Unit) },
|
private val clearComposerDraftLambda: () -> Result<Unit> = { Result.success(Unit) },
|
||||||
|
|
@ -90,6 +91,10 @@ class FakeBaseRoom(
|
||||||
return getMembersResult(limit)
|
return getMembersResult(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getDirectRoomMember(): RoomMember? {
|
||||||
|
return getDirectRoomMemberResult()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun subscribeToSync() {
|
override suspend fun subscribeToSync() {
|
||||||
subscribeToSyncLambda()
|
subscribeToSyncLambda()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue