Add suggestions section to InvitePeopleView

This commit is contained in:
ganfra 2026-01-19 17:35:39 +01:00
parent 23d3066a38
commit 7be66061e2
11 changed files with 227 additions and 48 deletions

View file

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

View file

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

View file

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

View file

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