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

View File

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

View File

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

View File

@@ -85,6 +85,13 @@ interface BaseRoom : Closeable {
*/
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.
*/

View File

@@ -17,10 +17,7 @@ import kotlin.coroutines.CoroutineContext
* 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) {
val roomMembersState = membersStateFlow.value
val activeRoomMembers = roomMembersState.roomMembers()
?.filter { it.membership.isActive() }
.orEmpty()
val activeRoomMembers = membersStateFlow.value.activeRoomMembers()
val filteredMembers = if (query.isBlank()) {
activeRoomMembers
} else {

View File

@@ -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.core.RoomId
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.isDm
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.first
private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
data class RecentDirectRoom(
val roomId: RoomId,
val matrixUser: MatrixUser,
)
suspend fun MatrixClient.getRecentDirectRooms(
maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN,
): List<RecentDirectRoom> {
val result = mutableListOf<RecentDirectRoom>()
/**
* Returns a [Flow] of [RecentDirectRoom] from recently visited DM rooms.
* The flow emits items lazily, allowing callers to filter and take only what they need.
* Use [kotlinx.coroutines.flow.take] to limit results and stop iteration early.
*/
fun MatrixClient.getRecentDirectRooms(): Flow<RecentDirectRoom> = flow {
val foundUserIds = mutableSetOf<UserId>()
getRecentlyVisitedRooms().getOrNull()?.let { roomIds ->
roomIds
.mapNotNull { roomId -> getRoom(roomId) }
.filter { it.isDm() && it.isJoined() }
.map { room ->
val otherUser = room.getMembers().getOrNull()
?.firstOrNull { it.userId != sessionId }
?.takeIf { foundUserIds.add(it.userId) }
?.toMatrixUser()
if (otherUser != null) {
result.add(
RecentDirectRoom(room.roomId, otherUser)
)
// Return early to avoid useless computation
if (result.size >= maxNumberOfResults) {
return@map
}
val recentlyVisitedRooms = getRecentlyVisitedRooms().getOrDefault(emptyList())
for (roomId in recentlyVisitedRooms) {
getRoom(roomId)?.use { room ->
val info = room.info()
if (info.isDm && info.currentUserMembership == CurrentUserMembership.JOINED) {
val otherUser = room.getDirectRoomMember()?.toMatrixUser()
if (otherUser != null && foundUserIds.add(otherUser.userId)) {
emit(RecentDirectRoom(room.roomId, otherUser))
}
}
}
}
return result
}
suspend fun BaseRoom.isJoined(): Boolean {
return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED
}

View File

@@ -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.RoomMembershipObserver
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.RoomPowerLevelsValues
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) {
runCatchingExceptions {
RoomMemberMapper.map(innerRoom.member(userId.value))

View File

@@ -55,6 +55,7 @@ class FakeBaseRoom(
private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() },
private var updateMembersResult: () -> Unit = { 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 loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
private val clearComposerDraftLambda: () -> Result<Unit> = { Result.success(Unit) },
@@ -90,6 +91,10 @@ class FakeBaseRoom(
return getMembersResult(limit)
}
override suspend fun getDirectRoomMember(): RoomMember? {
return getDirectRoomMemberResult()
}
override suspend fun subscribeToSync() {
subscribeToSyncLambda()
}