Room : continue improving members loading

This commit is contained in:
ganfra
2023-04-21 14:39:47 +02:00
parent f02ee307cc
commit b0152059ff
26 changed files with 329 additions and 166 deletions

View File

@@ -101,18 +101,12 @@ class RoomFlowNode @AssistedInject constructor(
private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room
/*
room.fetchMembers()
.map {
room.updateMembers()
}
room.updateMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
*/
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View File

@@ -55,6 +55,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)

View File

@@ -75,6 +75,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun TimelineView(
@@ -100,11 +101,11 @@ fun TimelineView(
itemsIndexed(
items = state.timelineItems,
contentType = { _, timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.key() },
key = { _, timelineItem -> timelineItem.identifier() },
) { index, timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked
)
@@ -114,27 +115,22 @@ fun TimelineView(
}
}
/*
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
onLoadMore = ::onReachedLoadMore
)
*/
}
}
private fun TimelineItem.key(): String {
return when (this) {
is TimelineItem.Event -> id
is TimelineItem.Virtual -> id
}
}
private fun TimelineItem.contentType(): Int {
// Todo optimize for each subtype
return when (this) {
is TimelineItem.Event -> 0
is TimelineItem.Virtual -> 1
}
private fun TimelineItem.contentType() = when (this) {
is TimelineItem.Event -> content.javaClass.simpleName
is TimelineItem.Virtual -> model.javaClass.simpleName
}.also {
Timber.v("ContentType = $it")
}
@Composable

View File

@@ -25,7 +25,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
@@ -43,7 +42,6 @@ class TimelineItemEventFactory @Inject constructor(
val senderDisplayName: String?
val senderAvatarUrl: String?
Timber.v("SenderProfile($currentSender) = ${currentTimelineItem.event.senderProfile}")
when (val senderProfile = currentTimelineItem.event.senderProfile) {
ProfileTimelineDetails.Unavailable,
ProfileTimelineDetails.Pending,

View File

@@ -131,7 +131,6 @@ class MessagesPresenterTest {
appCoroutineScope = this,
room = matrixRoom
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,

View File

@@ -14,6 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -31,6 +33,8 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),

View File

@@ -53,6 +53,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View File

@@ -17,75 +17,60 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult
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.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.getDmMember
import io.element.android.libraries.matrix.api.room.memberCount
import kotlinx.coroutines.Dispatchers
import io.element.android.libraries.matrix.api.room.getDmMemberFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember {
val leaveRoomWarning = remember {
mutableStateOf<LeaveRoomWarning?>(null)
}
var error by remember {
val error = remember {
mutableStateOf<RoomDetailsError?>(null)
}
val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState)
val dmMemberState by room.getDmMemberFlow()
.collectAsState(initial = null, context = coroutineDispatchers.computation)
val memberCount: MutableState<Async<Int>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(Unit) {
suspend {
room.updateMembers()
.map { room.memberCount() }
}.executeResult(memberCount)
}
val dmMember = room.getDmMember()
val roomType = if (dmMember != null) {
RoomDetailsType.Dm(dmMember)
} else {
RoomDetailsType.Room
}
val roomType = getRoomType(dmMemberState)
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
is RoomDetailsEvent.LeaveRoom -> {
if (event.needsConfirmation) {
leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount.value)
} else {
coroutineScope.launch(Dispatchers.IO) {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error = RoomDetailsError.AlertGeneric
}
leaveRoomWarning = null
}
}
coroutineScope.leaveRoom(
needsConfirmation = event.needsConfirmation,
memberCount = memberCount,
leaveRoomWarning = leaveRoomWarning,
error = error,
)
}
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null
RoomDetailsEvent.ClearError -> error = null
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error.value = null
}
}
@@ -95,12 +80,56 @@ class RoomDetailsPresenter @Inject constructor(
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic,
memberCount = memberCount.value,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
displayLeaveRoomWarning = leaveRoomWarning,
error = error,
roomType = roomType,
displayLeaveRoomWarning = leaveRoomWarning.value,
error = error.value,
roomType = roomType.value,
eventSink = ::handleEvents,
)
}
@Composable
private fun getRoomType(dmMember: RoomMember?): State<RoomDetailsType> = remember(dmMember) {
derivedStateOf {
if (dmMember != null) {
RoomDetailsType.Dm(dmMember)
} else {
RoomDetailsType.Room
}
}
}
@Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> = remember(membersState) {
derivedStateOf {
when (membersState) {
MatrixRoomMembersState.Unknown -> Async.Uninitialized
MatrixRoomMembersState.Pending -> Async.Loading()
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure)
}
}
}
private fun CoroutineScope.leaveRoom(
needsConfirmation: Boolean,
memberCount: Async<Int>,
leaveRoomWarning: MutableState<LeaveRoomWarning?>,
error: MutableState<RoomDetailsError?>,
) = launch(coroutineDispatchers.io) {
if (needsConfirmation) {
leaveRoomWarning.value = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
} else {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error.value = RoomDetailsError.AlertGeneric
}
leaveRoomWarning.value = null
}
}
}

View File

@@ -14,15 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.messages.fixtures
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import io.element.android.libraries.matrix.ui.model.MatrixUser
// TODO Move to common module to reuse
internal fun testCoroutineDispatchers() = CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)
sealed interface RoomMemberListEvents {
data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
}

View File

@@ -28,9 +28,6 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
@ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor(
@@ -46,12 +43,9 @@ class RoomMemberListNode @AssistedInject constructor(
private val callbacks = plugins<Callback>()
private fun onUserSelected(matrixUser: MatrixUser) {
val member = room.getMember(matrixUser.id)
if (member != null) {
callbacks.forEach { it.openRoomMemberDetails(member) }
} else {
Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}")
private fun openRoomMemberDetails(roomMember: RoomMember) {
callbacks.forEach {
it.openRoomMemberDetails(roomMember)
}
}
@@ -62,7 +56,7 @@ class RoomMemberListNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onUserSelected = ::onUserSelected,
onMemberSelected = this::openRoomMemberDetails,
)
}
}

View File

@@ -18,8 +18,10 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.features.userlist.api.UserListDataStore
@@ -27,10 +29,16 @@ import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
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.room.RoomMember
import io.element.android.libraries.matrix.api.room.getMemberFlow
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
@@ -39,6 +47,8 @@ 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 coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy {
@@ -51,17 +61,33 @@ class RoomMemberListPresenter @Inject constructor(
@Composable
override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope()
val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
val selectedMember: MutableState<RoomMember?> = remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withContext(coroutineDispatchers.io) {
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
}
}
fun handleEvents(roomMemberListEvents: RoomMemberListEvents) {
when (roomMemberListEvents) {
is RoomMemberListEvents.SelectUser -> coroutineScope.loadRoomMember(roomMemberListEvents.user, selectedMember)
}
}
return RoomMemberListState(
allUsers = allUsers.value,
userListState = userListState
userListState = userListState,
selectedRoomMember = selectedMember.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.loadRoomMember(user: MatrixUser, selectedMember: MutableState<RoomMember?>) = launch(coroutineDispatchers.io) {
selectedMember.value = room.getMemberFlow(user.id).firstOrNull()
}
}

View File

@@ -18,11 +18,13 @@ 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.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
val selectedRoomMember: RoomMember? = null,
val eventSink: (RoomMemberListEvents) -> Unit,
)

View File

@@ -39,4 +39,5 @@ internal fun aRoomMemberListState(
RoomMemberListState(
userListState = aUserListState().copy(searchResults = searchResults),
allUsers = allUsers,
eventSink = {}
)

View File

@@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
@@ -51,6 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.CenterAlignedT
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
@OptIn(ExperimentalMaterial3Api::class)
@@ -59,8 +61,19 @@ fun RoomMemberListView(
state: RoomMemberListState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onMemberSelected: (RoomMember) -> Unit = {},
) {
LaunchedEffect(state.selectedRoomMember) {
if (state.selectedRoomMember != null) {
onMemberSelected(state.selectedRoomMember)
}
}
fun onUserSelected(user: MatrixUser) {
state.eventSink(RoomMemberListEvents.SelectUser(user))
}
Scaffold(
topBar = {
if (!state.userListState.isSearchActive) {
@@ -76,7 +89,7 @@ fun RoomMemberListView(
) {
UserListView(
state = state.userListState,
onUserSelected = onUserSelected,
onUserSelected = ::onUserSelected,
)
if (!state.userListState.isSearchActive) {

View File

@@ -18,26 +18,40 @@ 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.designsystem.components.avatar.AvatarData
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.ui.model.MatrixUser
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.skip
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomUserListDataSource @Inject constructor(
private val room: MatrixRoom
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return room.membersFlow.value.filter { member ->
if (query.isBlank()) {
true
} else {
override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
val roomMembers = room.membersStateFlow
.dropWhile { it !is MatrixRoomMembersState.Ready}
.first()
.roomMembers()
val filteredMembers = if (query.isBlank()) {
roomMembers
} else {
roomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true)
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}.map(::mapMemberToMatrixUser)
}
filteredMembers.map(::mapMemberToMatrixUser)
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
@@ -55,5 +69,4 @@ class RoomUserListDataSource @Inject constructor(
)
)
}
}

View File

@@ -26,6 +26,7 @@ import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.libraries.architecture.Async
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.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
@@ -34,6 +35,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
@@ -45,11 +47,12 @@ import org.junit.Test
class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver()
private val testCoroutineDispatchers = testCoroutineDispatchers()
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -68,13 +71,13 @@ class RoomDetailsPresenterTests {
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
room.givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
val finalState = awaitItem()
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0))
}
@@ -83,7 +86,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -97,30 +100,27 @@ class RoomDetailsPresenterTests {
@Test
fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Error(Throwable()))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val room = aMatrixRoom(isPublic = false).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
@@ -129,15 +129,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember()))
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
@@ -146,15 +145,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
@@ -163,15 +161,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents()
@@ -188,14 +185,11 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable())
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull()
@@ -211,13 +205,11 @@ fun aMatrixRoom(
displayName: String = "A fallback display name",
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
) = FakeMatrixRoom(
roomId = roomId,
name = name,
initialMembers = members,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,

View File

@@ -29,8 +29,12 @@ import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList
import org.junit.Test
@@ -38,6 +42,8 @@ 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())
@@ -52,7 +58,14 @@ class RoomMemberListPresenterTests {
userListDataStore: UserListDataStore,
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
}
val presenter = RoomMemberListPresenter(userListFactory, userListDataSource, userListDataStore)
val fakeRoom = FakeMatrixRoom()
val presenter = RoomMemberListPresenter(
userListPresenterFactory = userListFactory,
userListDataSource = userListDataSource,
userListDataStore = userListDataStore,
room = fakeRoom,
coroutineDispatchers = testCoroutineDispatchers
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {

View File

@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
interface UserListDataSource {
//TODO should probably have a flow
suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser?
}

View File

@@ -47,7 +47,9 @@ suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>, errorMa
}
suspend fun <T> (suspend () -> Result<T>).executeResult(state: MutableState<Async<T>>) {
state.value = Async.Loading()
if (state.value !is Async.Success) {
state.value = Async.Loading()
}
this().fold(
onSuccess = {
state.value = Async.Success(it)

View File

@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import java.io.Closeable
interface MatrixRoom : Closeable {
@@ -41,10 +42,10 @@ interface MatrixRoom : Closeable {
/**
* The current loaded members as a StateFlow.
* Initial value is an emptyList.
* Initial value is [MatrixRoomMembersState.Unknown].
* To update them you should call [updateMembers].
*/
val membersFlow: StateFlow<List<RoomMember>>
val membersStateFlow: StateFlow<MatrixRoomMembersState>
/**
* Try to load the room members and update the membersFlow.
@@ -70,18 +71,21 @@ interface MatrixRoom : Closeable {
suspend fun leave(): Result<Unit>
}
fun MatrixRoom.getMember(userId: UserId): RoomMember? {
return membersFlow.value.find { it.userId == userId }
}
fun MatrixRoom.getDmMember(): RoomMember? {
return if (membersFlow.value.size == 2 && isDirect && isEncrypted) {
membersFlow.value.find { it.userId != this.sessionId }
} else {
null
fun MatrixRoom.getMemberFlow(userId: UserId): Flow<RoomMember?> {
return membersStateFlow.map { state ->
state.roomMembers().find {
it.userId == userId
}
}
}
fun MatrixRoom.memberCount(): Int {
return membersFlow.value.size
fun MatrixRoom.getDmMemberFlow(): Flow<RoomMember?> {
return membersStateFlow.map { state ->
val members = state.roomMembers()
if (members.size == 2 && isDirect && isEncrypted) {
members.find { it.userId != this.sessionId }
} else {
null
}
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
sealed interface MatrixRoomMembersState {
object Unknown : MatrixRoomMembersState
object Pending : MatrixRoomMembersState
data class Error(val failure: Throwable) : MatrixRoomMembersState
data class Ready(val roomMembers: List<RoomMember>) : MatrixRoomMembersState
}
fun MatrixRoomMembersState.roomMembers(): List<RoomMember> {
return when (this) {
is MatrixRoomMembersState.Ready -> roomMembers
else -> emptyList()
}
}

View File

@@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
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 io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
@@ -48,10 +48,10 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
override val membersFlow: StateFlow<List<RoomMember>>
get() = cachedMembers
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
get() = _membersStateFlow
private var cachedMembers = MutableStateFlow<List<RoomMember>>(emptyList())
private var _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
override fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow
@@ -122,9 +122,14 @@ class RustMatrixRoom(
get() = innerRoom.isDirect()
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
_membersStateFlow.value = MatrixRoomMembersState.Pending
runCatching {
cachedMembers.value = innerRoom.members().map(RoomMemberMapper::map)
}
innerRoom.members().map(RoomMemberMapper::map)
}.onSuccess {
_membersStateFlow.value = MatrixRoomMembersState.Ready(it)
}.onFailure {
_membersStateFlow.value = MatrixRoomMembersState.Error(it)
}.map { }
}
override suspend fun userDisplayName(userId: UserId): Result<String?> =

View File

@@ -143,12 +143,15 @@ class RustMatrixTimeline(
requiredState = listOf(
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.name", value = ""),
RequiredState(key = "m.room.join_rule", value = ""),
),
timelineLimit = 20.toUInt()
)
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
fetchMembers()
launch {
fetchMembers()
}
listenerTokens += result.taskHandle
result.items
}

View File

@@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
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 io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@@ -44,18 +44,16 @@ class FakeMatrixRoom(
override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true,
override val isDirect: Boolean = false,
initialMembers: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {
private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null)
private var dmMember: RoomMember? = null
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var leaveRoomError: Throwable? = null
override val membersFlow: MutableStateFlow<List<RoomMember>> = MutableStateFlow(initialMembers)
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override suspend fun updateMembers(): Result<Unit> {
return updateMembersResult
@@ -117,12 +115,12 @@ class FakeMatrixRoom(
this.leaveRoomError = throwable
}
fun givenFetchMemberResult(result: Result<Unit>) {
updateMembersResult = result
fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state
}
fun givenDmMember(roomMember: RoomMember) {
this.dmMember = roomMember
fun givenUpdateMembersResult(result: Result<Unit>) {
updateMembersResult = result
}
fun givenUserDisplayNameResult(displayName: Result<String?>) {

View File

@@ -34,4 +34,6 @@ dependencies {
implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.test)
implementation(projects.services.appnavstate.test)
implementation(projects.services.appnavstate.test)
implementation(projects.libraries.core)
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.tests.testutils
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
fun testCoroutineDispatchers(
testScheduler: TestCoroutineScheduler? = null,
) = CoroutineDispatchers(
io = UnconfinedTestDispatcher(testScheduler),
computation = UnconfinedTestDispatcher(testScheduler),
main = UnconfinedTestDispatcher(testScheduler),
diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler),
)
fun testCoroutineDispatchers(
io: TestDispatcher = UnconfinedTestDispatcher(),
computation: TestDispatcher = UnconfinedTestDispatcher(),
main: TestDispatcher = UnconfinedTestDispatcher(),
diffUpdateDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) = CoroutineDispatchers(
io = io,
computation = computation,
main = main,
diffUpdateDispatcher = diffUpdateDispatcher,
)