Browse Source

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
feature/jme/open-room-member-details-when-clicking-on-user-data
Marco Romano 1 year ago committed by GitHub
parent
commit
b51c19af19
  1. 1
      changelog.d/385.feature
  2. 2
      features/roomdetails/impl/build.gradle.kts
  3. 3
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
  4. 16
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
  5. 16
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt
  6. 5
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
  7. 71
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
  8. 26
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
  9. 94
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
  10. 228
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
  11. 25
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
  12. 123
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
  13. 2
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
  14. 7
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
  15. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png
  16. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png
  17. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png
  18. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png
  19. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png
  20. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png
  21. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png
  22. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png
  23. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png
  24. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png
  25. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png
  26. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png

1
changelog.d/385.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Show pending invitations in room members list

2
features/roomdetails/impl/build.gradle.kts

@ -40,7 +40,6 @@ dependencies { @@ -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 { @@ -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)

3
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -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( @@ -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 })
}
}
}

16
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt → features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt

@ -17,31 +17,17 @@ @@ -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
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt → features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt

@ -16,27 +16,23 @@ @@ -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( @@ -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
}
}

5
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt

@ -16,8 +16,7 @@ @@ -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
}

71
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt

@ -18,54 +18,73 @@ package io.element.android.features.roomdetails.impl.members @@ -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
}
},
)
}
}

26
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt

@ -16,12 +16,30 @@ @@ -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
}

94
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt

@ -17,27 +17,93 @@ @@ -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)

228
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt

@ -16,20 +16,31 @@ @@ -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 @@ -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( @@ -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( @@ -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( @@ -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 @@ -147,5 +301,9 @@ fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::cla
@Composable
private fun ContentToPreview(state: RoomMemberListState) {
RoomMemberListView(state)
RoomMemberListView(
state = state,
onBackPressed = {},
onMemberSelected = {}
)
}

25
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt

@ -24,6 +24,8 @@ import io.element.android.features.roomdetails.impl.LeaveRoomWarning @@ -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 { @@ -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 { @@ -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( @@ -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,
)

123
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt

@ -20,62 +20,111 @@ import app.cash.molecule.RecompositionClock @@ -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)
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.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.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()
}
val userListDataStore = UserListDataStore()
val userListFactory = object : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userListDataSource: UserListDataSource,
userListDataStore: UserListDataStore,
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
}
@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()
}
val fakeRoom = FakeMatrixRoom()
val presenter = RoomMemberListPresenter(
userListPresenterFactory = userListFactory,
userListDataSource = userListDataSource,
userListDataStore = userListDataStore,
room = fakeRoom,
coroutineDispatchers = testCoroutineDispatchers
)
}
@Test
fun `search for something which is not found`() = 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)
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()
Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
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)

2
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt

@ -22,7 +22,7 @@ import app.cash.turbine.test @@ -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

7
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt

@ -17,7 +17,6 @@ @@ -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( @@ -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
}

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save