Show pending invitations in room members list (#385)
Splits a Room's member list in 2 showing pending invitees first and then the actual room member. This simple user facing change entails a host of under the hood changes: - It copies the logic from the `userlist` module and merges it into the `roomdetails` module removing all details not related to the member list (e.g. gets rid of multiple selection, debouncing etc.). - Uncouples the `roomdetails` module from the `userlist` one. Now leaving only the `createroom` module to depend on the `userlist` module. Therefore the `userlist` module could be in the future completely removed and merged into the `createroom` module. - Changes the room members count in the room details screen to only show the members who have joined (i.e. don't count those still in the invited state). Missed ACs: - This change does not make the member list live update. Discussion is ongoing on how to make this technically feasible. Parent issue: - https://github.com/vector-im/element-x-android/issues/246
This commit is contained in:
1
changelog.d/385.feature
Normal file
1
changelog.d/385.feature
Normal file
@@ -0,0 +1 @@
|
||||
Show pending invitations in room members list
|
||||
@@ -40,7 +40,6 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.features.userlist.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
api(projects.features.roomdetails.api)
|
||||
implementation(libs.coil.compose)
|
||||
@@ -51,7 +50,6 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.userlist.impl)
|
||||
testImplementation(projects.features.userlist.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
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.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -124,7 +125,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
MatrixRoomMembersState.Unknown -> Async.Uninitialized
|
||||
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
|
||||
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
|
||||
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.size)
|
||||
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,31 +17,17 @@
|
||||
package io.element.android.features.roomdetails.impl.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.RoomMember
|
||||
import javax.inject.Named
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
interface RoomMemberBindsModule {
|
||||
|
||||
@Binds
|
||||
@Named("RoomMembers")
|
||||
fun bindRoomMemberUserListDataSource(dataSource: RoomUserListDataSource): UserListDataSource
|
||||
}
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
object RoomMemberProvidesModule {
|
||||
object RoomMemberModule {
|
||||
|
||||
@Provides
|
||||
fun provideRoomMemberDetailsPresenterFactory(
|
||||
@@ -16,27 +16,23 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
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.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomUserListDataSource @Inject constructor(
|
||||
class RoomMemberListDataSource @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : UserListDataSource {
|
||||
) {
|
||||
|
||||
override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
|
||||
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
|
||||
val roomMembers = room.membersStateFlow
|
||||
.dropWhile { it !is MatrixRoomMembersState.Ready }
|
||||
.first()
|
||||
@@ -50,11 +46,7 @@ class RoomUserListDataSource @Inject constructor(
|
||||
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||
}
|
||||
}
|
||||
filteredMembers.map(RoomMember::toMatrixUser)
|
||||
}
|
||||
|
||||
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
||||
return null
|
||||
filteredMembers
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface RoomMemberListEvents {
|
||||
data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
|
||||
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
||||
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
|
||||
}
|
||||
|
||||
@@ -18,54 +18,73 @@ package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import io.element.android.features.userlist.api.SelectionMode
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
import io.element.android.features.userlist.api.UserListPresenter
|
||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
class RoomMemberListPresenter @Inject constructor(
|
||||
private val userListPresenterFactory: UserListPresenter.Factory,
|
||||
@Named("RoomMembers") private val userListDataSource: UserListDataSource,
|
||||
private val userListDataStore: UserListDataStore,
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<RoomMemberListState> {
|
||||
|
||||
private val userListPresenter by lazy {
|
||||
userListPresenterFactory.create(
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userListDataSource,
|
||||
userListDataStore,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomMemberListState {
|
||||
val userListState = userListPresenter.present()
|
||||
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
|
||||
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchResults by remember {
|
||||
mutableStateOf<RoomMemberSearchResultState>(RoomMemberSearchResultState.NotSearching)
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
|
||||
val members = roomMemberListDataSource.search("").groupBy { it.membership }
|
||||
roomMembers = Async.Success(
|
||||
RoomMembers(
|
||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
searchResults = if (searchQuery.isEmpty()) {
|
||||
RoomMemberSearchResultState.NotSearching
|
||||
} else {
|
||||
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
|
||||
if (results.isEmpty()) RoomMemberSearchResultState.NoResults
|
||||
else RoomMemberSearchResultState.Results(
|
||||
RoomMembers(
|
||||
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
|
||||
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RoomMemberListState(
|
||||
allUsers = allUsers.value,
|
||||
userListState = userListState,
|
||||
roomMembers = roomMembers,
|
||||
searchQuery = searchQuery,
|
||||
searchResults = searchResults,
|
||||
isSearchActive = isSearchActive,
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,30 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import io.element.android.features.userlist.api.UserListState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class RoomMemberListState(
|
||||
val allUsers: Async<ImmutableList<MatrixUser>>,
|
||||
val userListState: UserListState,
|
||||
val roomMembers: Async<RoomMembers>,
|
||||
val searchQuery: String,
|
||||
val searchResults: RoomMemberSearchResultState,
|
||||
val isSearchActive: Boolean,
|
||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||
)
|
||||
|
||||
data class RoomMembers(
|
||||
val invited: ImmutableList<RoomMember>,
|
||||
val joined: ImmutableList<RoomMember>
|
||||
)
|
||||
|
||||
sealed interface RoomMemberSearchResultState {
|
||||
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
|
||||
object NotSearching : RoomMemberSearchResultState
|
||||
|
||||
/** The search has completed, but no results were found. */
|
||||
object NoResults : RoomMemberSearchResultState
|
||||
|
||||
/** The search has completed, and some matching users were found. */
|
||||
data class Results(val results: RoomMembers) : RoomMemberSearchResultState
|
||||
}
|
||||
|
||||
@@ -17,27 +17,93 @@
|
||||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.api.aUserListState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
|
||||
override val values: Sequence<RoomMemberListState>
|
||||
get() = sequenceOf(
|
||||
aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))),
|
||||
aRoomMemberListState(allUsers = Async.Loading())
|
||||
aRoomMemberListState(
|
||||
roomMembers = Async.Success(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor(), aWalter()),
|
||||
joined = persistentListOf(anAlice(), aBob()),
|
||||
)
|
||||
)
|
||||
),
|
||||
aRoomMemberListState(roomMembers = Async.Loading()),
|
||||
aRoomMemberListState().copy(isSearchActive = false),
|
||||
aRoomMemberListState().copy(isSearchActive = true),
|
||||
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
aRoomMemberListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
searchResults = RoomMemberSearchResultState.Results(
|
||||
RoomMembers(
|
||||
invited = persistentListOf(aVictor()),
|
||||
joined = persistentListOf(anAlice()),
|
||||
)
|
||||
),
|
||||
),
|
||||
aRoomMemberListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "something-with-no-results",
|
||||
searchResults = RoomMemberSearchResultState.NoResults
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRoomMemberListState(
|
||||
searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
|
||||
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
|
||||
) =
|
||||
RoomMemberListState(
|
||||
userListState = aUserListState().copy(searchResults = searchResults),
|
||||
allUsers = allUsers,
|
||||
)
|
||||
roomMembers: Async<RoomMembers> = Async.Uninitialized,
|
||||
searchResults: RoomMemberSearchResultState = RoomMemberSearchResultState.NotSearching,
|
||||
) = RoomMemberListState(
|
||||
roomMembers = roomMembers,
|
||||
searchQuery = "",
|
||||
searchResults = searchResults,
|
||||
isSearchActive = false,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
fun aRoomMember(
|
||||
userId: UserId = UserId("@alice:server.org"),
|
||||
displayName: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
membership: RoomMembershipState = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous: Boolean = false,
|
||||
powerLevel: Long = 0L,
|
||||
normalizedPowerLevel: Long = 0L,
|
||||
isIgnored: Boolean = false,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
membership = membership,
|
||||
isNameAmbiguous = isNameAmbiguous,
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
)
|
||||
|
||||
fun aRoomMemberList() = listOf(
|
||||
anAlice(),
|
||||
aBob(),
|
||||
aRoomMember(UserId("@carol:server.org"), "Carol"),
|
||||
aRoomMember(UserId("@david:server.org"), "David"),
|
||||
aRoomMember(UserId("@eve:server.org"), "Eve"),
|
||||
aRoomMember(UserId("@justin:server.org"), "Justin"),
|
||||
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
|
||||
aRoomMember(UserId("@susie:server.org"), "Susie"),
|
||||
aVictor(),
|
||||
aWalter(),
|
||||
)
|
||||
|
||||
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice")
|
||||
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob")
|
||||
|
||||
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
||||
|
||||
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
|
||||
|
||||
@@ -16,20 +16,31 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -39,37 +50,43 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.userlist.api.components.SearchSingleUserResultItem
|
||||
import io.element.android.features.userlist.api.components.UserListView
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.isLoading
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
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.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
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.Text
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomMemberListView(
|
||||
state: RoomMemberListState,
|
||||
onBackPressed: () -> Unit,
|
||||
onMemberSelected: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onMemberSelected: (UserId) -> Unit = {},
|
||||
) {
|
||||
|
||||
fun onUserSelected(user: MatrixUser) {
|
||||
onMemberSelected(user.userId)
|
||||
fun onUserSelected(roomMember: RoomMember) {
|
||||
onMemberSelected(roomMember.userId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!state.userListState.isSearchActive) {
|
||||
if (!state.isSearchActive) {
|
||||
RoomMemberListTopBar(onBackPressed = onBackPressed)
|
||||
}
|
||||
}
|
||||
@@ -80,33 +97,26 @@ fun RoomMemberListView(
|
||||
.padding(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
UserListView(
|
||||
state = state.userListState,
|
||||
onUserSelected = ::onUserSelected,
|
||||
)
|
||||
Column {
|
||||
RoomMemberSearchBar(
|
||||
query = state.searchQuery,
|
||||
state = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
|
||||
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
|
||||
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
|
||||
onUserSelected = ::onUserSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.userListState.isSearchActive) {
|
||||
if (state.allUsers is Async.Success) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
||||
item {
|
||||
val memberCount = state.allUsers.state.count()
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount),
|
||||
style = ElementTextStyles.Regular.callout,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
items(state.allUsers.state) { matrixUser ->
|
||||
SearchSingleUserResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
onClick = { onUserSelected(matrixUser) }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (state.allUsers.isLoading()) {
|
||||
if (!state.isSearchActive) {
|
||||
if (state.roomMembers is Async.Success) {
|
||||
RoomMemberList(
|
||||
roomMembers = state.roomMembers.state,
|
||||
onUserSelected = ::onUserSelected,
|
||||
)
|
||||
} else if (state.roomMembers.isLoading()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
@@ -116,9 +126,73 @@ fun RoomMemberListView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomMemberList(
|
||||
roomMembers: RoomMembers,
|
||||
onUserSelected: (RoomMember) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
||||
if (roomMembers.invited.isNotEmpty()) {
|
||||
roomMemberListSection(
|
||||
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
|
||||
members = roomMembers.invited,
|
||||
onMemberSelected = { onUserSelected(it) }
|
||||
)
|
||||
}
|
||||
if (roomMembers.joined.isNotEmpty()) {
|
||||
val memberCount = roomMembers.joined.count()
|
||||
roomMemberListSection(
|
||||
headerText = { pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) },
|
||||
members = roomMembers.joined,
|
||||
onMemberSelected = { onUserSelected(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.roomMemberListSection(
|
||||
headerText: @Composable () -> String,
|
||||
members: ImmutableList<RoomMember>,
|
||||
onMemberSelected: (RoomMember) -> Unit,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
text = headerText(),
|
||||
style = ElementTextStyles.Regular.callout,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
items(members) { matrixUser ->
|
||||
RoomMemberListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
roomMember = matrixUser,
|
||||
onClick = { onMemberSelected(matrixUser) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomMemberListItem(
|
||||
roomMember: RoomMember,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
MatrixUserRow(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
matrixUser = MatrixUser(
|
||||
userId = roomMember.userId,
|
||||
displayName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl
|
||||
),
|
||||
avatarSize = AvatarSize.Custom(36.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomMemberListTopBar(
|
||||
private fun RoomMemberListTopBar(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
@@ -135,6 +209,86 @@ fun RoomMemberListTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomMemberSearchBar(
|
||||
query: String,
|
||||
state: RoomMemberSearchResultState,
|
||||
active: Boolean,
|
||||
placeHolderTitle: String,
|
||||
onActiveChanged: (Boolean) -> Unit,
|
||||
onTextChanged: (String) -> Unit,
|
||||
onUserSelected: (RoomMember) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
if (!active) {
|
||||
onTextChanged("")
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = onTextChanged,
|
||||
onSearch = { focusManager.clearFocus() },
|
||||
active = active,
|
||||
onActiveChange = onActiveChanged,
|
||||
modifier = modifier
|
||||
.padding(horizontal = if (!active) 16.dp else 0.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeHolderTitle,
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
},
|
||||
leadingIcon = if (active) {
|
||||
{ BackButton(onClick = { onActiveChanged(false) }) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = when {
|
||||
active && query.isNotEmpty() -> {
|
||||
{
|
||||
IconButton(onClick = { onTextChanged("") }) {
|
||||
Icon(Icons.Default.Close, stringResource(StringR.string.action_clear))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
!active -> {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(StringR.string.action_search),
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
},
|
||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||
content = {
|
||||
if (state is RoomMemberSearchResultState.Results) {
|
||||
RoomMemberList(
|
||||
roomMembers = state.results,
|
||||
onUserSelected = { onUserSelected(it) }
|
||||
)
|
||||
} else if (state is RoomMemberSearchResultState.NoResults) {
|
||||
Spacer(Modifier.size(80.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(StringR.string.common_no_results),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
|
||||
@@ -147,5 +301,9 @@ fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::cla
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: RoomMemberListState) {
|
||||
RoomMemberListView(state)
|
||||
RoomMemberListView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
onMemberSelected = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import io.element.android.features.roomdetails.impl.LeaveRoomWarning
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -90,7 +92,7 @@ class RoomDetailsPresenterTests {
|
||||
val room = aMatrixRoom()
|
||||
val roomMembers = listOf(
|
||||
aRoomMember(A_USER_ID),
|
||||
aRoomMember(A_USER_ID_2),
|
||||
aRoomMember(A_USER_ID_2, membership = RoomMembershipState.INVITE),
|
||||
)
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
@@ -112,7 +114,7 @@ class RoomDetailsPresenterTests {
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||
//skipItems(1)
|
||||
val successState = awaitItem()
|
||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(roomMembers.size))
|
||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
@@ -266,22 +268,3 @@ fun aMatrixRoom(
|
||||
isDirect = isDirect,
|
||||
)
|
||||
|
||||
fun aRoomMember(
|
||||
userId: UserId = A_USER_ID,
|
||||
displayName: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
membership: RoomMembershipState = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous: Boolean = false,
|
||||
powerLevel: Long = 0L,
|
||||
normalizedPowerLevel: Long = 0L,
|
||||
isIgnored: Boolean = false,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
membership = membership,
|
||||
isNameAmbiguous = isNameAmbiguous,
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
)
|
||||
|
||||
@@ -20,62 +20,111 @@ import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.features.roomdetails.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
|
||||
import io.element.android.features.userlist.api.SelectionMode
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
import io.element.android.features.userlist.api.UserListPresenter
|
||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.impl.DefaultUserListPresenter
|
||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberSearchResultState
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
import io.element.android.features.roomdetails.impl.members.aVictor
|
||||
import io.element.android.features.roomdetails.impl.members.aWalter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.internal.toImmutableList
|
||||
import org.junit.Test
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class RoomMemberListPresenterTests {
|
||||
|
||||
private val testCoroutineDispatchers = testCoroutineDispatchers()
|
||||
|
||||
@Test
|
||||
fun `present - search is done automatically on start, but is async`() = runTest {
|
||||
val searchResult = listOf(aMatrixUser())
|
||||
val userListDataSource = FakeUserListDataSource().apply {
|
||||
givenSearchResult(searchResult)
|
||||
}
|
||||
val userListDataStore = UserListDataStore()
|
||||
val userListFactory = object : UserListPresenter.Factory {
|
||||
override fun create(
|
||||
args: UserListPresenterArgs,
|
||||
userListDataSource: UserListDataSource,
|
||||
userListDataStore: UserListDataStore,
|
||||
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
|
||||
}
|
||||
val fakeRoom = FakeMatrixRoom()
|
||||
val presenter = RoomMemberListPresenter(
|
||||
userListPresenterFactory = userListFactory,
|
||||
userListDataSource = userListDataSource,
|
||||
userListDataStore = userListDataStore,
|
||||
room = fakeRoom,
|
||||
coroutineDispatchers = testCoroutineDispatchers
|
||||
)
|
||||
fun `search is done automatically on start, but is async`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
|
||||
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
|
||||
Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
|
||||
Truth.assertThat(initialState.searchQuery).isEmpty()
|
||||
Truth.assertThat(initialState.searchResults).isEqualTo(RoomMemberSearchResultState.NotSearching)
|
||||
Truth.assertThat(initialState.isSearchActive).isFalse()
|
||||
|
||||
val loadedState = awaitItem()
|
||||
Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
|
||||
Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java)
|
||||
Truth.assertThat((loadedState.roomMembers as Async.Success).state.invited).isEqualTo(listOf(aVictor(), aWalter()))
|
||||
Truth.assertThat((loadedState.roomMembers as Async.Success).state.joined).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open search`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val loadedState = awaitItem()
|
||||
|
||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||
|
||||
val searchActiveState = awaitItem()
|
||||
Truth.assertThat((searchActiveState.isSearchActive)).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search for something which is not found`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val loadedState = awaitItem()
|
||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||
val searchActiveState = awaitItem()
|
||||
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
||||
val searchQueryUpdatedState = awaitItem()
|
||||
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
|
||||
val searchSearchResultDelivered = awaitItem()
|
||||
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.NoResults::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search for something which is found`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val loadedState = awaitItem()
|
||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
||||
val searchActiveState = awaitItem()
|
||||
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
|
||||
val searchQueryUpdatedState = awaitItem()
|
||||
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
|
||||
val searchSearchResultDelivered = awaitItem()
|
||||
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java)
|
||||
Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName)
|
||||
.isEqualTo("Alice")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
private fun createDataSource(
|
||||
matrixRoom: MatrixRoom = aMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
},
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
private fun createPresenter(
|
||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||
) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers)
|
||||
|
||||
@@ -22,7 +22,7 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.features.roomdetails.aMatrixClient
|
||||
import io.element.android.features.roomdetails.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class RoomMember(
|
||||
val userId: UserId,
|
||||
@@ -30,12 +29,6 @@ data class RoomMember(
|
||||
val isIgnored: Boolean,
|
||||
)
|
||||
|
||||
fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
enum class RoomMembershipState {
|
||||
BAN, INVITE, JOIN, KNOCK, LEAVE
|
||||
}
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user