Merge pull request #6045 from element-hq/feature/fga/invite_people_suggestions
Add suggestions section to InvitePeopleView
This commit is contained in:
commit
dd68db3fc1
17 changed files with 239 additions and 60 deletions
|
|
@ -11,6 +11,7 @@ package io.element.android.features.invitepeople.impl
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.RoomMembershipState
|
||||
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.ui.strings.CommonStrings
|
||||
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.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val MAX_SUGGESTIONS_COUNT = 5
|
||||
|
||||
@AssistedInject
|
||||
class DefaultInvitePeoplePresenter(
|
||||
@Assisted private val joinedRoom: JoinedRoom?,
|
||||
|
|
@ -78,6 +85,34 @@ class DefaultInvitePeoplePresenter(
|
|||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||
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()) {
|
||||
if (joinedRoom == null) {
|
||||
val result = matrixClient.getJoinedRoom(roomId)
|
||||
|
|
@ -118,6 +153,7 @@ class DefaultInvitePeoplePresenter(
|
|||
is DefaultInvitePeopleEvents.ToggleUser -> {
|
||||
selectedUsers.toggleUser(event.user)
|
||||
searchResults.toggleUser(event.user)
|
||||
// suggestions will automatically update via derivedStateOf when selectedUsers changes
|
||||
}
|
||||
is InvitePeopleEvents.SendInvites -> {
|
||||
room.dataOrNull()?.let {
|
||||
|
|
@ -140,6 +176,7 @@ class DefaultInvitePeoplePresenter(
|
|||
searchResults = searchResults.value,
|
||||
showSearchLoader = showSearchLoader.value,
|
||||
sendInvitesAction = sendInvitesAction.value,
|
||||
suggestions = suggestions,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,5 +25,6 @@ data class DefaultInvitePeopleState(
|
|||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
override val isSearchActive: Boolean,
|
||||
override val sendInvitesAction: AsyncAction<Unit>,
|
||||
val suggestions: ImmutableList<InvitableUser>,
|
||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
||||
) : InvitePeopleState
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ private fun aDefaultInvitePeopleState(
|
|||
isSearchActive: Boolean = false,
|
||||
showSearchLoader: Boolean = false,
|
||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
suggestions: List<InvitableUser> = aMatrixUserList()
|
||||
.take(5)
|
||||
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
|
||||
): DefaultInvitePeopleState {
|
||||
return DefaultInvitePeopleState(
|
||||
room = room,
|
||||
|
|
@ -111,6 +114,7 @@ private fun aDefaultInvitePeopleState(
|
|||
isSearchActive = isSearchActive,
|
||||
showSearchLoader = showSearchLoader,
|
||||
sendInvitesAction = sendInvitesAction,
|
||||
suggestions = suggestions.toImmutableList(),
|
||||
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.preview.ElementPreview
|
||||
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.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
|
@ -82,6 +84,10 @@ private fun InvitePeopleContentView(
|
|||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
fun toggleUser(user: MatrixUser) {
|
||||
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(user))
|
||||
}
|
||||
|
||||
InvitePeopleSearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = state.searchQuery,
|
||||
|
|
@ -97,17 +103,45 @@ private fun InvitePeopleContentView(
|
|||
)
|
||||
},
|
||||
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
|
||||
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
|
||||
onToggleUser = ::toggleUser,
|
||||
)
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = state.selectedUsers,
|
||||
autoScroll = true,
|
||||
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
)
|
||||
if (state.selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = state.selectedUsers,
|
||||
autoScroll = true,
|
||||
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,
|
||||
autoScroll = true,
|
||||
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.core.RoomId
|
||||
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.RoomMembersState
|
||||
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_2
|
||||
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.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMemberList
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
|
@ -67,13 +70,15 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
assertThat(initialState.canInvite).isFalse()
|
||||
assertThat(initialState.searchQuery).isEmpty()
|
||||
|
||||
skipItems(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - updates search active state`() = runTest {
|
||||
val presenter = createDefaultInvitePeoplePresenter()
|
||||
val presenter = createDefaultInvitePeoplePresenter(
|
||||
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
|
@ -85,11 +90,12 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
|
||||
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query")
|
||||
resultState.eventSink(InvitePeopleEvents.CloseSearch)
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
awaitItemAsDefault().also {
|
||||
assertThat(it.isSearchActive).isFalse()
|
||||
assertThat(it.searchQuery).isEmpty()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +281,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
val repository = FakeUserRepository()
|
||||
val presenter = createDefaultInvitePeoplePresenter(
|
||||
userRepository = repository,
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
)
|
||||
presenter.test {
|
||||
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(
|
||||
users: List<MatrixUser>,
|
||||
isSearching: Boolean = false
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
|
||||
private const val MAX_SUGGESTIONS_COUNT = 5
|
||||
|
||||
@AssistedInject
|
||||
class DefaultUserListPresenter(
|
||||
|
|
@ -53,7 +57,10 @@ class DefaultUserListPresenter(
|
|||
override fun present(): UserListState {
|
||||
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||
recentDirectRooms = matrixClient
|
||||
.getRecentDirectRooms()
|
||||
.take(MAX_SUGGESTIONS_COUNT)
|
||||
.toList()
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue