Merge pull request #353 from vector-im/feature/fga/some_room_related_fixes

Feature/fga/some room related fixes
This commit is contained in:
ganfra 2023-05-02 13:20:10 +02:00 committed by GitHub
commit ca6a47edcd
67 changed files with 674 additions and 392 deletions

View file

@ -19,8 +19,8 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.x.di.AppComponent import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.MatrixInitializer import io.element.android.x.initializer.MatrixInitializer

View file

@ -42,7 +42,6 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -103,7 +102,7 @@ class RoomFlowNode @AssistedInject constructor(
private fun fetchRoomMembers() = lifecycleScope.launch { private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room val room = inputs.room
room.fetchMembers() room.updateMembers()
.onFailure { .onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}") Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess { }.onSuccess {

View file

@ -24,7 +24,6 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.nodeTestHelper
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -81,19 +80,6 @@ class RoomFlowNodeTest {
roomMembershipObserver = RoomMembershipObserver() roomMembershipObserver = RoomMembershipObserver()
) )
@Test
fun `given a room flow node when initialized then it fetches room members`() {
// GIVEN
val room = FakeMatrixRoom()
val inputs = RoomFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs))
Truth.assertThat(room.areMembersFetched).isFalse()
// WHEN
roomFlowNode.nodeTestHelper()
// THEN
Truth.assertThat(room.areMembersFetched).isTrue()
}
@Test @Test
fun `given a room flow node when initialized then it loads messages entry point`() { fun `given a room flow node when initialized then it loads messages entry point`() {
// GIVEN // GIVEN

View file

@ -102,7 +102,7 @@ class CreateRoomRootPresenterTests {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:domain")) val matrixUser = MatrixUser(UserId("@name:domain"))
val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult:domain")) val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
fakeMatrixClient.givenFindDmResult(fakeDmResult) fakeMatrixClient.givenFindDmResult(fakeDmResult)

View file

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

View file

@ -102,6 +102,7 @@ private fun SheetContent(
// Crashes if sheetContent size is zero // Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp)) Box(modifier = modifier.size(1.dp))
} }
is ActionListState.Target.Success -> { is ActionListState.Target.Success -> {
val actions = target.actions val actions = target.actions
LazyColumn( LazyColumn(
@ -146,5 +147,11 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st
@Composable @Composable
private fun ContentToPreview(state: ActionListState) { private fun ContentToPreview(state: ActionListState) {
SheetContent(state) ActionListView(
state = state,
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
onActionSelected = { _, _ -> }
)
} }

View file

@ -100,11 +100,11 @@ fun TimelineView(
itemsIndexed( itemsIndexed(
items = state.timelineItems, items = state.timelineItems,
contentType = { _, timelineItem -> timelineItem.contentType() }, contentType = { _, timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.key() }, key = { _, timelineItem -> timelineItem.identifier() },
) { index, timelineItem -> ) { index, timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value, isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked onLongClick = onMessageLongClicked
) )
@ -122,21 +122,6 @@ fun TimelineView(
} }
} }
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
}
}
@Composable @Composable
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,

View file

@ -25,7 +25,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
ListUpdateCallback { ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) { override fun onChanged(position: Int, count: Int, payload: Any?) {
Timber.v("onChanged(position= $position, count= $count") Timber.d("onChanged(position= $position, count= $count)")
(position until position + count).forEach { (position until position + count).forEach {
// Invalidate cache // Invalidate cache
itemStatesCache[it] = null itemStatesCache[it] = null
@ -33,13 +33,13 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
} }
override fun onMoved(fromPosition: Int, toPosition: Int) { override fun onMoved(fromPosition: Int, toPosition: Int) {
Timber.v("onMoved(fromPosition= $fromPosition, toPosition= $toPosition") Timber.d("onMoved(fromPosition= $fromPosition, toPosition= $toPosition)")
val model = itemStatesCache.removeAt(fromPosition) val model = itemStatesCache.removeAt(fromPosition)
itemStatesCache.add(toPosition, model) itemStatesCache.add(toPosition, model)
} }
override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
Timber.v("onInserted(position= $position, count= $count") Timber.d("onInserted(position= $position, count= $count)")
itemStatesCache.invalidateLast() itemStatesCache.invalidateLast()
repeat(count) { repeat(count) {
itemStatesCache.add(position, null) itemStatesCache.add(position, null)
@ -47,7 +47,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
} }
override fun onRemoved(position: Int, count: Int) { override fun onRemoved(position: Int, count: Int) {
Timber.v("onRemoved(position= $position, count= $count") Timber.d("onRemoved(position= $position, count= $count)")
itemStatesCache.invalidateLast() itemStatesCache.invalidateLast()
repeat(count) { repeat(count) {
itemStatesCache.removeAt(position) itemStatesCache.removeAt(position)

View file

@ -31,6 +31,11 @@ sealed interface TimelineItem {
is Virtual -> id is Virtual -> id
} }
fun contentType(): String = when (this) {
is Event -> content.type
is Virtual -> model.type
}
@Immutable @Immutable
data class Virtual( data class Virtual(
val id: String, val id: String,

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent( data class TimelineItemEmoteContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"
}

View file

@ -20,4 +20,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
data class TimelineItemEncryptedContent( data class TimelineItemEncryptedContent(
val data: UnableToDecryptContent.Data val data: UnableToDecryptContent.Data
) : TimelineItemEventContent ) : TimelineItemEventContent {
override val type: String = "TimelineItemEncryptedContent"
}

View file

@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface TimelineItemEventContent sealed interface TimelineItemEventContent {
val type: String
}

View file

@ -23,4 +23,6 @@ data class TimelineItemImageContent(
val imageMeta: MediaResolver.Meta, val imageMeta: MediaResolver.Meta,
val blurhash: String?, val blurhash: String?,
val aspectRatio: Float val aspectRatio: Float
) : TimelineItemEventContent ) : TimelineItemEventContent{
override val type: String = "TimelineItemImageContent"
}

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent( data class TimelineItemNoticeContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
object TimelineItemRedactedContent : TimelineItemEventContent object TimelineItemRedactedContent : TimelineItemEventContent{
override val type: String = "TimelineItemRedactedContent"
}

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent( data class TimelineItemTextContent(
override val body: String, override val body: String,
override val htmlDocument: Document? override val htmlDocument: Document?
) : TimelineItemTextBasedContent ) : TimelineItemTextBasedContent{
override val type: String = "TimelineItemTextContent"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
object TimelineItemUnknownContent : TimelineItemEventContent object TimelineItemUnknownContent : TimelineItemEventContent {
override val type: String = "TimelineItemUnknownContent"
}

View file

@ -18,4 +18,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual
data class TimelineItemDaySeparatorModel( data class TimelineItemDaySeparatorModel(
val formattedDate: String val formattedDate: String
) : TimelineItemVirtualModel ) : TimelineItemVirtualModel {
override val type: String = "TimelineItemDaySeparatorModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemLoadingModel : TimelineItemVirtualModel object TimelineItemLoadingModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemLoadingModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemReadMarkerModel : TimelineItemVirtualModel object TimelineItemReadMarkerModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemReadMarkerModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemTimelineStartModel : TimelineItemVirtualModel object TimelineItemTimelineStartModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemTimelineStartModel"
}

View file

@ -16,4 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemUnknownVirtualModel"
}

View file

@ -19,4 +19,6 @@ package io.element.android.features.messages.impl.timeline.model.virtual
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface TimelineItemVirtualModel sealed interface TimelineItemVirtualModel {
val type: String
}

View file

@ -135,7 +135,6 @@ class MessagesPresenterTest {
mediaPickerProvider = PickerProvider(isInTest = true), mediaPickerProvider = PickerProvider(isInTest = true),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,

View file

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

View file

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

View file

@ -59,7 +59,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
object RoomMemberList : NavTarget object RoomMemberList : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -74,14 +74,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
NavTarget.RoomMemberList -> { NavTarget.RoomMemberList -> {
val roomMemberListCallback = object : RoomMemberListNode.Callback { val roomMemberListCallback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMember: RoomMember) { override fun openRoomMemberDetails(roomMemberId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(roomMember)) backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
} }
} }
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember))) createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
} }
} }
} }

View file

@ -17,98 +17,70 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable 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.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.RoomMembershipObserver
import kotlinx.coroutines.Dispatchers import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
) : Presenter<RoomDetailsState> { ) : Presenter<RoomDetailsState> {
private val roomMemberDetailsPresenter by lazy {
val dmMember = runBlocking {
room.getDmMember().firstOrNull()
}
if (dmMember != null) {
RoomMemberDetailsPresenter(matrixClient.sessionId, room, dmMember)
} else {
null
}
}
@Composable @Composable
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember { val leaveRoomWarning = remember {
mutableStateOf<LeaveRoomWarning?>(null) mutableStateOf<LeaveRoomWarning?>(null)
} }
var error by remember { val error = remember {
mutableStateOf<RoomDetailsError?>(null) mutableStateOf<RoomDetailsError?>(null)
} }
LaunchedEffect(Unit) {
val memberCount by produceState<Async<Int>>(initialValue = Async.Loading(null)) { room.updateMembers()
room.members().map { it.count() }
.onEach { value = Async.Success(it) }
.catch { value = Async.Failure(it) }
.launchIn(coroutineScope)
} }
val dmMember by room.getDmMember().collectAsState(initial = null) val membersState by room.membersStateFlow.collectAsState()
val roomType = if (dmMember != null) { val memberCount by getMemberCount(membersState)
RoomDetailsType.Dm(dmMember!!) val dmMember by room.getDirectRoomMember(membersState)
} else { val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
RoomDetailsType.Room val roomType = getRoomType(dmMember)
}
fun handleEvents(event: RoomDetailsEvent) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
is RoomDetailsEvent.LeaveRoom -> { is RoomDetailsEvent.LeaveRoom -> {
if (event.needsConfirmation) { coroutineScope.leaveRoom(
leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount) needsConfirmation = event.needsConfirmation,
} else { memberCount = memberCount,
coroutineScope.launch(Dispatchers.IO) { leaveRoomWarning = leaveRoomWarning,
room.leave() error = error,
.onSuccess { )
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error = RoomDetailsError.AlertGeneric
}
leaveRoomWarning = null
}
}
} }
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error = null RoomDetailsEvent.ClearError -> error.value = null
} }
} }
val roomMemberDetailsState = if (dmMember != null) { val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
roomMemberDetailsPresenter?.present()
} else {
null
}
return RoomDetailsState( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId.value,
@ -118,11 +90,66 @@ class RoomDetailsPresenter @Inject constructor(
roomTopic = room.topic, roomTopic = room.topic,
memberCount = memberCount, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
displayLeaveRoomWarning = leaveRoomWarning, displayLeaveRoomWarning = leaveRoomWarning.value,
error = error, error = error.value,
roomType = roomType, roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@Composable
private fun roomMemberDetailsPresenter(dmMemberState: RoomMember?) = remember(dmMemberState) {
dmMemberState?.let { roomMember ->
roomMembersDetailsPresenterFactory.create(roomMember.userId)
}
}
@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>> {
return remember(membersState) {
derivedStateOf {
when (membersState) {
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)
}
}
}
}
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

@ -117,7 +117,7 @@ fun RoomDetailsView(
} }
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
val memberCount = (state.memberCount as? Async.Success<Int>)?.state val memberCount = state.memberCount.dataOrNull()
MembersSection( MembersSection(
memberCount = memberCount, memberCount = memberCount,
isLoading = state.memberCount.isLoading(), isLoading = state.memberCount.isLoading(),

View file

@ -25,6 +25,7 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import javax.inject.Named import javax.inject.Named
@ -48,8 +49,8 @@ object RoomMemberProvidesModule {
room: MatrixRoom, room: MatrixRoom,
): RoomMemberDetailsPresenter.Factory { ): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient.sessionId, room, roomMember) return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
} }
} }
} }

View file

@ -14,15 +14,10 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.test.UnconfinedTestDispatcher
// TODO Move to common module to reuse sealed interface RoomMemberListEvents {
internal fun testCoroutineDispatchers() = CoroutineDispatchers( data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
io = UnconfinedTestDispatcher(), }
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)

View file

@ -26,35 +26,25 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor( class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val room: MatrixRoom,
private val presenter: RoomMemberListPresenter, private val presenter: RoomMemberListPresenter,
private val coroutineScope: CoroutineScope,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberDetails(roomMember: RoomMember) fun openRoomMemberDetails(roomMemberId: UserId)
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
private fun onUserSelected(matrixUser: MatrixUser) = coroutineScope.launch { private fun openRoomMemberDetails(roomMemberId: UserId) {
val member = room.getMember(matrixUser.id).firstOrNull() callbacks.forEach {
if (member != null) { it.openRoomMemberDetails(roomMemberId)
callbacks.forEach { it.openRoomMemberDetails(member) }
} else {
Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}")
} }
} }
@ -65,7 +55,7 @@ class RoomMemberListNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackPressed = { navigateUp() }, onBackPressed = { navigateUp() },
onUserSelected = ::onUserSelected, onMemberSelected = this::openRoomMemberDetails,
) )
} }
} }

View file

@ -27,10 +27,11 @@ import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter 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.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
@ -39,6 +40,8 @@ class RoomMemberListPresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory, private val userListPresenterFactory: UserListPresenter.Factory,
@Named("RoomMembers") private val userListDataSource: UserListDataSource, @Named("RoomMembers") private val userListDataSource: UserListDataSource,
private val userListDataStore: UserListDataStore, private val userListDataStore: UserListDataStore,
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy { private val userListPresenter by lazy {
@ -53,14 +56,16 @@ class RoomMemberListPresenter @Inject constructor(
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
val userListState = userListPresenter.present() val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) } val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { withContext(coroutineDispatchers.io) {
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList()) allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
} }
} }
return RoomMemberListState( return RoomMemberListState(
allUsers = allUsers.value, allUsers = allUsers.value,
userListState = userListState userListState = userListState,
) )
} }
} }

View file

@ -18,11 +18,11 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.userlist.api.UserListState import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async 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 io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState( data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>, val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState, val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
) )

View file

@ -51,6 +51,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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -59,8 +60,13 @@ fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {}, onMemberSelected: (UserId) -> Unit = {},
) { ) {
fun onUserSelected(user: MatrixUser) {
onMemberSelected(user.id)
}
Scaffold( Scaffold(
topBar = { topBar = {
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {
@ -76,7 +82,7 @@ fun RoomMemberListView(
) { ) {
UserListView( UserListView(
state = state.userListState, state = state.userListState,
onUserSelected = onUserSelected, onUserSelected = ::onUserSelected,
) )
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {

View file

@ -18,27 +18,39 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.core.bool.orFalse 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.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId 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.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.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class RoomUserListDataSource @Inject constructor( class RoomUserListDataSource @Inject constructor(
private val room: MatrixRoom private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : UserListDataSource { ) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> { override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
return room.members().firstOrNull().orEmpty().filter { member -> val roomMembers = room.membersStateFlow
if (query.isBlank()) { .dropWhile { it !is MatrixRoomMembersState.Ready }
true .first()
} else { .roomMembers()
.orEmpty()
val filteredMembers = if (query.isBlank()) {
roomMembers
} else {
roomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) member.userId.value.contains(query, ignoreCase = true)
|| member.displayName?.contains(query, ignoreCase = true).orFalse() || member.displayName?.contains(query, ignoreCase = true).orFalse()
} }
}.map(::mapMemberToMatrixUser) }
filteredMembers.map(::mapMemberToMatrixUser)
} }
override suspend fun getProfile(userId: UserId): MatrixUser? { override suspend fun getProfile(userId: UserId): MatrixUser? {
@ -56,5 +68,4 @@ class RoomUserListDataSource @Inject constructor(
) )
) )
} }
} }

View file

@ -30,8 +30,8 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.RoomMember
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.androidutils.R as AndroidUtilsR import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ -43,18 +43,18 @@ class RoomMemberDetailsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val member: RoomMember, val roomMemberId: UserId,
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.member) private val presenter = presenterFactory.create(inputs.roomMemberId)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
fun onShareUser() { fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.member.userId) val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink -> permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent( startSharePlainTextIntent(
context = context, context = context,

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl.members.details package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -28,28 +29,36 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.room.getRoomMember
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor( class RoomMemberDetailsPresenter @AssistedInject constructor(
private val currentUserSessionId: SessionId, private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
@Assisted private val roomMember: RoomMember, @Assisted private val roomMemberId: UserId,
) : Presenter<RoomMemberDetailsState> { ) : Presenter<RoomMemberDetailsState> {
interface Factory { interface Factory {
fun create(roomMember: RoomMember): RoomMemberDetailsPresenter fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
} }
@Composable @Composable
override fun present(): RoomMemberDetailsState { override fun present(): RoomMemberDetailsState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) } var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var isBlocked = remember { mutableStateOf(roomMember.isIgnored) } val roomMember by room.getRoomMember(roomMemberId)
// the room member is not really live...
val isBlocked = remember {
mutableStateOf(roomMember?.isIgnored.orFalse())
}
LaunchedEffect(Unit) {
room.updateMembers()
}
fun handleEvents(event: RoomMemberDetailsEvents) { fun handleEvents(event: RoomMemberDetailsEvents) {
when (event) { when (event) {
@ -58,7 +67,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Block confirmationDialog = ConfirmationDialog.Block
} else { } else {
confirmationDialog = null confirmationDialog = null
coroutineScope.blockUser(roomMember.userId, isBlocked) coroutineScope.blockUser(roomMemberId, isBlocked)
} }
} }
is RoomMemberDetailsEvents.UnblockUser -> { is RoomMemberDetailsEvents.UnblockUser -> {
@ -66,41 +75,50 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Unblock confirmationDialog = ConfirmationDialog.Unblock
} else { } else {
confirmationDialog = null confirmationDialog = null
coroutineScope.unblockUser(roomMember.userId, isBlocked) coroutineScope.unblockUser(roomMemberId, isBlocked)
} }
} }
RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
} }
} }
val userName by produceState(initialValue = roomMember.displayName) { val userName by produceState(initialValue = roomMember?.displayName) {
room.userDisplayName(roomMember.userId).onSuccess { displayName -> room.userDisplayName(roomMemberId).onSuccess { displayName ->
if (displayName != null) value = displayName if (displayName != null) value = displayName
} }
} }
val userAvatar by produceState(initialValue = roomMember.avatarUrl) { val userAvatar by produceState(initialValue = roomMember?.avatarUrl) {
room.userAvatarUrl(roomMember.userId).onSuccess { avatarUrl -> room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl ->
if (avatarUrl != null) value = avatarUrl if (avatarUrl != null) value = avatarUrl
} }
} }
return RoomMemberDetailsState( return RoomMemberDetailsState(
userId = roomMember.userId.value, userId = roomMemberId.value,
userName = userName, userName = userName,
avatarUrl = userAvatar, avatarUrl = userAvatar,
isBlocked = isBlocked.value, isBlocked = isBlocked.value,
displayConfirmationDialog = confirmationDialog, displayConfirmationDialog = confirmationDialog,
isCurrentUser = roomMember.userId == currentUserSessionId, isCurrentUser = roomMember?.userId == client.sessionId,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch { private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
room.ignoreUser(userId).onSuccess { isBlockedState.value = true } client.ignoreUser(userId)
.map {
isBlockedState.value = true
room.updateMembers()
}
} }
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch { private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
room.unignoreUser(userId).onSuccess { isBlockedState.value = false } client.unignoreUser(userId)
.map {
isBlockedState.value = false
room.updateMembers()
}
} }
} }

View file

@ -24,18 +24,25 @@ import io.element.android.features.roomdetails.impl.LeaveRoomWarning
import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId 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.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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -47,11 +54,21 @@ import org.junit.Test
class RoomDetailsPresenterTests { class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver() private val roomMembershipObserver = RoomMembershipObserver()
private val testCoroutineDispatchers = testCoroutineDispatchers()
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
}
}
return RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers, roomMemberDetailsPresenterFactory)
}
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -60,7 +77,7 @@ class RoomDetailsPresenterTests {
Truth.assertThat(initialState.roomName).isEqualTo(room.name) Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic) Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -69,23 +86,42 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - room member count is calculated asynchronously`() = runTest { fun `present - room member count is calculated asynchronously`() = runTest {
val error = RuntimeException()
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) val roomMembers = listOf(
aRoomMember(A_USER_ID),
aRoomMember(A_USER_ID_2),
)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
val finalState = awaitItem() room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0)) val loadingState = awaitItem()
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
//skipItems(1)
val failureState = awaitItem()
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
//skipItems(1)
val successState = awaitItem()
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(roomMembers.size))
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null) val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -98,36 +134,21 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - initial state with DM member sets custom DM roomType`() = runTest { fun `present - initial state with DM member sets custom DM roomType`() = runTest {
val room = aMatrixRoom(name = null).apply { val myRoomMember = aRoomMember(A_SESSION_ID)
givenDmMember(aRoomMember()) val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
).apply {
val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
} }
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// It's not configured yet in the first iteration Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Room)
// Once updated, the RoomDetailsType becomes 'Dm'
val updatedState = awaitItem()
Truth.assertThat(updatedState.roomType).isEqualTo(RoomDetailsType.Dm(aRoomMember()))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable()))
}
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -135,15 +156,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false) val room = aMatrixRoom(isPublic = false).apply {
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
@ -152,15 +172,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember())) val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
@ -169,15 +188,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation shows a generic warning`() = runTest { fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
@ -186,15 +204,14 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave without confirmation leaves the room`() = runTest { fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom().apply {
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -211,14 +228,11 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable()) givenLeaveRoomError(Throwable())
} }
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver) val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem() val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull() Truth.assertThat(errorState.error).isNotNull()
@ -228,24 +242,28 @@ class RoomDetailsPresenterTests {
} }
} }
fun aMatrixClient(
sessionId: SessionId = A_SESSION_ID,
) = FakeMatrixClient()
fun aMatrixRoom( fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
displayName: String = "A fallback display name", displayName: String = "A fallback display name",
topic: String? = "A topic", topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg", avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false,
) = FakeMatrixRoom( ) = FakeMatrixRoom(
roomId = roomId, roomId = roomId,
name = name, name = name,
displayName = displayName, displayName = displayName,
topic = topic, topic = topic,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
members = members,
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
isPublic = isPublic, isPublic = isPublic,
isDirect = isDirect,
) )
fun aRoomMember( fun aRoomMember(

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.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.architecture.Async 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.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList import okhttp3.internal.toImmutableList
import org.junit.Test import org.junit.Test
@ -38,6 +42,8 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomMemberListPresenterTests { class RoomMemberListPresenterTests {
private val testCoroutineDispatchers = testCoroutineDispatchers()
@Test @Test
fun `present - search is done automatically on start, but is async`() = runTest { fun `present - search is done automatically on start, but is async`() = runTest {
val searchResult = listOf(aMatrixUser()) val searchResult = listOf(aMatrixUser())
@ -52,7 +58,14 @@ class RoomMemberListPresenterTests {
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
) = DefaultUserListPresenter(args, userListDataSource, 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -20,12 +20,13 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth 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.aMatrixRoom
import io.element.android.features.roomdetails.aRoomMember import io.element.android.features.roomdetails.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents 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.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -33,14 +34,17 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomMemberDetailsPresenterTests { class RoomMemberDetailsPresenterTests {
private val matrixClient = aMatrixClient()
@Test @Test
fun `present - returns the room member's data, then updates it if needed`() = runTest { fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success("A custom name")) givenUserDisplayNameResult(Result.success("A custom name"))
givenUserAvatarUrlResult(Result.success("A custom avatar")) givenUserAvatarUrlResult(Result.success("A custom avatar"))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -58,12 +62,13 @@ class RoomMemberDetailsPresenterTests {
@Test @Test
fun `present - will recover when retrieving room member details fails`() = runTest { fun `present - will recover when retrieving room member details fails`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.failure(Throwable())) givenUserDisplayNameResult(Result.failure(Throwable()))
givenUserAvatarUrlResult(Result.failure(Throwable())) givenUserAvatarUrlResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -77,12 +82,13 @@ class RoomMemberDetailsPresenterTests {
@Test @Test
fun `present - will fallback to original data if the updated data is null`() = runTest { fun `present - will fallback to original data if the updated data is null`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success(null)) givenUserDisplayNameResult(Result.success(null))
givenUserAvatarUrlResult(Result.success(null)) givenUserAvatarUrlResult(Result.success(null))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
} }
val roomMember = aRoomMember(displayName = "Alice") val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -98,7 +104,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val roomMember = aRoomMember() val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember) val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -119,7 +125,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val roomMember = aRoomMember() val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember) val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -136,7 +142,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val roomMember = aRoomMember() val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember) val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.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 import io.element.android.libraries.matrix.ui.model.MatrixUser
interface UserListDataSource { interface UserListDataSource {
//TODO should probably have a flow
suspend fun search(query: String): List<MatrixUser> suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): 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>>) { 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( this().fold(
onSuccess = { onSuccess = {
state.value = Async.Success(it) state.value = Async.Success(it)

View file

@ -99,7 +99,7 @@ private fun InitialsAvatar(
) { ) {
Text( Text(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
text = avatarData.getInitial(), text = avatarData.initial,
fontSize = (avatarData.size.dp / 2).value.sp, fontSize = (avatarData.size.dp / 2).value.sp,
color = Color.White, color = Color.White,
) )

View file

@ -30,8 +30,37 @@ data class AvatarData(
@IgnoredOnParcel @IgnoredOnParcel
val size: AvatarSize = AvatarSize.MEDIUM val size: AvatarSize = AvatarSize.MEDIUM
) : Parcelable { ) : Parcelable {
fun getInitial(): String {
val firstChar = name?.firstOrNull() ?: id.getOrNull(1) ?: '?' @IgnoredOnParcel
return firstChar.uppercase() val initial by lazy {
(name?.takeIf { it.isNotBlank() } ?: id)
.let { dn ->
var startIndex = 0
val initial = dn[startIndex]
if (initial in listOf('@', '#', '+') && dn.length > 1) {
startIndex++
}
var length = 1
var first = dn[startIndex]
// LEFT-TO-RIGHT MARK
if (dn.length >= 2 && 0x200e == first.code) {
startIndex++
first = dn[startIndex]
}
// check if its the start of a surrogate pair
if (first.code in 0xD800..0xDBFF && dn.length > startIndex + 1) {
val second = dn[startIndex + 1]
if (second.code in 0xDC00..0xDFFF) {
length++
}
}
dn.substring(startIndex, startIndex + length)
}
.uppercase()
} }
} }

View file

@ -21,12 +21,12 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetDefaults import androidx.compose.material.ModalBottomSheetDefaults
import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.contentColorFor
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -44,7 +44,7 @@ fun ModalBottomSheetLayout(
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large, sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface, sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor), sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor, scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
content: @Composable () -> Unit = {} content: @Composable () -> Unit = {}

View file

@ -35,6 +35,8 @@ interface MatrixClient : Closeable {
val invitesDataSource: RoomSummaryDataSource val invitesDataSource: RoomSummaryDataSource
fun getRoom(roomId: RoomId): MatrixRoom? fun getRoom(roomId: RoomId): MatrixRoom?
fun findDM(userId: UserId): MatrixRoom? fun findDM(userId: UserId): MatrixRoom?
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
suspend fun createDM(userId: UserId): Result<RoomId> suspend fun createDM(userId: UserId): Result<RoomId>
fun startSync() fun startSync()

View file

@ -18,12 +18,15 @@ package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
interface MatrixRoom : Closeable { interface MatrixRoom : Closeable {
val sessionId: SessionId
val roomId: RoomId val roomId: RoomId
val name: String? val name: String?
val bestName: String val bestName: String
@ -36,20 +39,22 @@ interface MatrixRoom : Closeable {
val isDirect: Boolean val isDirect: Boolean
val isPublic: Boolean val isPublic: Boolean
fun members() : Flow<List<RoomMember>> /**
* The current loaded members as a StateFlow.
* Initial value is [MatrixRoomMembersState.Unknown].
* To update them you should call [updateMembers].
*/
val membersStateFlow: StateFlow<MatrixRoomMembersState>
fun updateMembers() /**
* Try to load the room members and update the membersFlow.
fun getMember(userId: UserId): Flow<RoomMember?> */
suspend fun updateMembers(): Result<Unit>
fun getDmMember(): Flow<RoomMember?>
fun syncUpdateFlow(): Flow<Long> fun syncUpdateFlow(): Flow<Long>
fun timeline(): MatrixTimeline fun timeline(): MatrixTimeline
suspend fun fetchMembers(): Result<Unit>
suspend fun userDisplayName(userId: UserId): Result<String?> suspend fun userDisplayName(userId: UserId): Result<String?>
suspend fun userAvatarUrl(userId: UserId): Result<String?> suspend fun userAvatarUrl(userId: UserId): Result<String?>
@ -62,10 +67,6 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun leave(): Result<Unit> suspend fun leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit> suspend fun acceptInvitation(): Result<Unit>

View file

@ -0,0 +1,35 @@
/*
* 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
data class Pending(val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Error(val failure: Throwable, val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Ready(val roomMembers: List<RoomMember>) : MatrixRoomMembersState
}
fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
return when (this) {
is MatrixRoomMembersState.Ready -> roomMembers
is MatrixRoomMembersState.Pending -> prevRoomMembers
is MatrixRoomMembersState.Error -> prevRoomMembers
else -> null
}
}

View file

@ -16,11 +16,8 @@
package io.element.android.libraries.matrix.api.room package io.element.android.libraries.matrix.api.room
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class RoomMember( data class RoomMember(
val userId: UserId, val userId: UserId,
val displayName: String?, val displayName: String?,
@ -30,7 +27,7 @@ data class RoomMember(
val powerLevel: Long, val powerLevel: Long,
val normalizedPowerLevel: Long, val normalizedPowerLevel: Long,
val isIgnored: Boolean, val isIgnored: Boolean,
) : Parcelable )
enum class RoomMembershipState { enum class RoomMembershipState {
BAN, INVITE, JOIN, KNOCK, LEAVE BAN, INVITE, JOIN, KNOCK, LEAVE

View file

@ -198,7 +198,7 @@ class RustMatrixClient constructor(
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
val fullRoom = slidingSyncRoom.fullRoom() ?: return null val fullRoom = slidingSyncRoom.fullRoom() ?: return null
return RustMatrixRoom( return RustMatrixRoom(
currentUserId = sessionId, sessionId = sessionId,
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow, slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
slidingSyncRoom = slidingSyncRoom, slidingSyncRoom = slidingSyncRoom,
innerRoom = fullRoom, innerRoom = fullRoom,
@ -212,6 +212,18 @@ class RustMatrixClient constructor(
return roomId?.let { getRoom(it) } return roomId?.let { getRoom(it) }
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
runCatching {
client.ignoreUser(userId.value)
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
runCatching {
client.unignoreUser(userId.value)
}
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(dispatchers.io) { override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(dispatchers.io) {
runCatching { runCatching {
val rustParams = RustCreateRoomParameters( val rustParams = RustCreateRoomParameters(

View file

@ -17,17 +17,19 @@
package io.element.android.libraries.matrix.impl.room package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@ -38,10 +40,9 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
class RustMatrixRoom( class RustMatrixRoom(
private val currentUserId: UserId, override val sessionId: SessionId,
private val slidingSyncUpdateFlow: Flow<UpdateSummary>, private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
private val slidingSyncRoom: SlidingSyncRoom, private val slidingSyncRoom: SlidingSyncRoom,
private val innerRoom: Room, private val innerRoom: Room,
@ -49,6 +50,11 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom { ) : MatrixRoom {
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
get() = _membersStateFlow
private var _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
private val timeline by lazy { private val timeline by lazy {
RustMatrixTimeline( RustMatrixTimeline(
matrixRoom = this, matrixRoom = this,
@ -59,33 +65,6 @@ class RustMatrixRoom(
) )
} }
private var membersFlow = MutableStateFlow<List<RoomMember>>(emptyList())
override fun members(): Flow<List<RoomMember>> {
return membersFlow.onSubscription { updateMembers() }
}
override fun updateMembers() {
val updatedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
membersFlow.tryEmit(updatedMembers)
}
override fun getMember(userId: UserId): Flow<RoomMember?> {
return membersFlow.map { members -> members.find { it.userId == userId } }
}
override fun getDmMember(): Flow<RoomMember?> {
return membersFlow.map { members ->
if (members.size == 2 && isDirect && isEncrypted) {
members.find { it.userId != currentUserId }
} else {
null
}
}
}
override fun syncUpdateFlow(): Flow<Long> { override fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow return slidingSyncUpdateFlow
.filter { .filter {
@ -148,9 +127,16 @@ class RustMatrixRoom(
override val isDirect: Boolean override val isDirect: Boolean
get() = innerRoom.isDirect() get() = innerRoom.isDirect()
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()
_membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers)
runCatching { runCatching {
innerRoom.fetchMembers() innerRoom.members().map(RoomMemberMapper::map)
}.map {
_membersStateFlow.value = MatrixRoomMembersState.Ready(it)
}.onFailure {
_membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = it)
} }
} }
@ -206,31 +192,15 @@ class RustMatrixRoom(
} }
override suspend fun acceptInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun acceptInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
kotlin.runCatching { runCatching {
innerRoom.acceptInvitation() innerRoom.acceptInvitation()
} }
} }
override suspend fun rejectInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun rejectInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
kotlin.runCatching { runCatching {
innerRoom.rejectInvitation() innerRoom.rejectInvitation()
} }
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> {
return runCatching {
getRustMember(userId)?.ignore() ?: error("No member with userId $userId exists in room $roomId")
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> {
return runCatching {
getRustMember(userId)?.unignore() ?: error("No member with userId $userId exists in room $roomId")
}
}
private fun getRustMember(userId: UserId): RustRoomMember? {
return innerRoom.members().find { it.userId() == userId.value }
}
} }

View file

@ -42,6 +42,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
class RustMatrixTimeline( class RustMatrixTimeline(
private val matrixRoom: MatrixRoom, private val matrixRoom: MatrixRoom,
@ -51,6 +52,8 @@ class RustMatrixTimeline(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixTimeline { ) : MatrixTimeline {
private val isInit = AtomicBoolean(false)
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
MutableStateFlow(emptyList()) MutableStateFlow(emptyList())
@ -95,6 +98,7 @@ class RustMatrixTimeline(
withContext(coroutineDispatchers.diffUpdateDispatcher) { withContext(coroutineDispatchers.diffUpdateDispatcher) {
this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems
} }
isInit.set(true)
} }
.onFailure { .onFailure {
Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})") Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})")
@ -105,6 +109,7 @@ class RustMatrixTimeline(
override fun dispose() { override fun dispose() {
Timber.v("Dispose timeline for room ${matrixRoom.roomId}") Timber.v("Dispose timeline for room ${matrixRoom.roomId}")
listenerTokens.dispose() listenerTokens.dispose()
isInit.set(false)
} }
/** /**
@ -125,6 +130,9 @@ class RustMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ") Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
if (!isInit.get()) {
throw IllegalStateException("Timeline is not init yet")
}
val paginationOptions = PaginationOptions.UntilNumItems( val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(), eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort() items = untilNumberOfItems.toUShort()
@ -141,15 +149,24 @@ class RustMatrixTimeline(
runCatching { runCatching {
val settings = RoomSubscription( val settings = RoomSubscription(
requiredState = listOf( requiredState = listOf(
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.canonical_alias", value = ""), RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.join_rules", value = ""), RequiredState(key = "m.room.join_rules", value = ""),
), ),
timelineLimit = null timelineLimit = null
) )
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings) val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
launch {
fetchMembers()
}
listenerTokens += result.taskHandle listenerTokens += result.taskHandle
result.items result.items
} }
} }
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()
}
}
} }

View file

@ -47,6 +47,8 @@ class FakeMatrixClient(
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),
) : MatrixClient { ) : MatrixClient {
private var ignoreUserResult: Result<Unit> = Result.success(Unit)
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID) private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID) private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null private var createDmFailure: Throwable? = null
@ -62,6 +64,14 @@ class FakeMatrixClient(
return findDmResult return findDmResult
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> {
return ignoreUserResult
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> {
return unignoreUserResult
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> { override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> {
delay(100) delay(100)
return createRoomResult return createRoomResult
@ -130,6 +140,14 @@ class FakeMatrixClient(
createDmResult = result createDmResult = result
} }
fun givenIgnoreUserResult(result: Result<Unit>) {
ignoreUserResult = result
}
fun givenUnignoreUserResult(result: Result<Unit>) {
unignoreUserResult = result
}
fun givenCreateDmError(failure: Throwable?) { fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure createDmFailure = failure
} }

View file

@ -16,21 +16,23 @@
package io.element.android.libraries.matrix.test.room package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.core.coroutine.errorFlow
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
class FakeMatrixRoom( class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID,
override val roomId: RoomId = A_ROOM_ID, override val roomId: RoomId = A_ROOM_ID,
override val name: String? = null, override val name: String? = null,
override val bestName: String = "", override val bestName: String = "",
@ -42,21 +44,16 @@ class FakeMatrixRoom(
override val alternativeAliases: List<String> = emptyList(), override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true, override val isPublic: Boolean = true,
override val isDirect: Boolean = false, override val isDirect: Boolean = false,
private val members: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom { ) : MatrixRoom {
private var ignoreResult: Result<Unit> = Result.success(Unit)
private var unignoreResult: Result<Unit> = Result.success(Unit)
private var userDisplayNameResult = Result.success<String?>(null) private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null) private var userAvatarUrlResult = Result.success<String?>(null)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit) private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit)
private var dmMember: RoomMember? = null
private var fetchMemberResult: Result<Unit> = Result.success(Unit)
private var ignoreResult = Result.success(Unit)
private var unignoreResult = Result.success(Unit)
var areMembersFetched: Boolean = false
private set
var isInviteAccepted: Boolean = false var isInviteAccepted: Boolean = false
private set private set
@ -66,6 +63,12 @@ class FakeMatrixRoom(
private var leaveRoomError: Throwable? = null private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override suspend fun updateMembers(): Result<Unit> {
return updateMembersResult
}
override fun syncUpdateFlow(): Flow<Long> { override fun syncUpdateFlow(): Flow<Long> {
return emptyFlow() return emptyFlow()
} }
@ -74,18 +77,6 @@ class FakeMatrixRoom(
return matrixTimeline return matrixTimeline
} }
override suspend fun fetchMembers(): Result<Unit> {
return fetchMemberResult.also { result ->
if (result.isSuccess) {
areMembersFetched = true
}
}
}
override fun getDmMember(): Flow<RoomMember?> {
return flowOf(dmMember)
}
override suspend fun userDisplayName(userId: UserId): Result<String?> { override suspend fun userDisplayName(userId: UserId): Result<String?> {
return userDisplayNameResult return userDisplayNameResult
} }
@ -94,20 +85,6 @@ class FakeMatrixRoom(
return userAvatarUrlResult return userAvatarUrlResult
} }
override fun members(): Flow<List<RoomMember>> {
return fetchMemberResult.fold(onSuccess = {
flowOf(members)
}, onFailure = {
errorFlow(it)
})
}
override fun updateMembers() = Unit
override fun getMember(userId: UserId): Flow<RoomMember?> {
return flowOf(members.find { it.userId == userId })
}
override suspend fun sendMessage(message: String): Result<Unit> { override suspend fun sendMessage(message: String): Result<Unit> {
delay(100) delay(100)
return Result.success(Unit) return Result.success(Unit)
@ -140,10 +117,6 @@ class FakeMatrixRoom(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> = ignoreResult
override suspend fun unignoreUser(userId: UserId): Result<Unit> = unignoreResult
override suspend fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) override suspend fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
override suspend fun acceptInvitation(): Result<Unit> { override suspend fun acceptInvitation(): Result<Unit> {
isInviteAccepted = true isInviteAccepted = true
@ -161,12 +134,12 @@ class FakeMatrixRoom(
this.leaveRoomError = throwable this.leaveRoomError = throwable
} }
fun givenFetchMemberResult(result: Result<Unit>) { fun givenRoomMembersState(state: MatrixRoomMembersState) {
fetchMemberResult = result membersStateFlow.value = state
} }
fun givenDmMember(roomMember: RoomMember) { fun givenUpdateMembersResult(result: Result<Unit>) {
this.dmMember = roomMember updateMembersResult = result
} }
fun givenUserDisplayNameResult(displayName: Result<String?>) { fun givenUserDisplayNameResult(displayName: Result<String?>) {
@ -185,7 +158,6 @@ class FakeMatrixRoom(
rejectInviteResult = result rejectInviteResult = result
} }
fun givenIgnoreResult(result: Result<Unit>) { fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result ignoreResult = result
} }

View file

@ -33,4 +33,7 @@ internal class MediaKeyer : Keyer<MediaResolver.Meta> {
} }
} }
private fun MediaResolver.Meta.toKey() = "${url}_${kind}" private fun MediaResolver.Meta.toKey(): String? {
if (url.isNullOrBlank()) return null
return "${url}_${kind}"
}

View file

@ -0,0 +1,70 @@
/*
* 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.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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
@Composable
fun MatrixRoom.getRoomMember(userId: UserId): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getRoomMember(roomMembersState = roomMembersState, userId = userId)
}
@Composable
fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {
roomMembers?.find {
it.userId == userId
}
}
}
}
@Composable
fun MatrixRoom.getDirectRoomMember(): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getDirectRoomMember(roomMembersState = roomMembersState)
}
@Composable
fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {
if (roomMembers == null) {
null
} else if (roomMembers.size == 2 && isDirect && isEncrypted) {
roomMembers.find { it.userId != this.sessionId }
} else {
null
}
}
}
}

View file

@ -34,4 +34,6 @@ dependencies {
implementation(libs.coroutines.test) implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.matrix.test)
implementation(projects.services.appnavstate.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,
)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b
size 4478 size 4483

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b
size 4478 size 4483

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:49aafbdb693cccd3ab7ca94e61f7a1de437766a29967dd89c4aaf5134b55c004 oid sha256:db69f27f60dd9d93bb4d313741b84aa4a3ed008d229590338514c7683c0e3a11
size 14860 size 14786

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e
size 4457 size 4490

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e
size 4457 size 4490

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:857fedd347931600aa5613565887384579b565efb86d5eeca6e93c13f5ff442c oid sha256:6c630475e03d86195a0ebcc57bd12934b799fee956c635b30df60913cd9a3f50
size 13994 size 16032

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ddbb6611ae83055106f7b67ec828542f8a896cafb49001ed0baef43633cc77c1 oid sha256:428372bd789ff5e83ba4d837bbb0592edd8a01571728c7b284c57ddb200226ed
size 8884 size 9407

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5c9a3c9f68a6627654856b03d2534ae1e4e8e600989bc3719407fbf8e17a7ab1 oid sha256:718cf6e3323ceb9bbf8b4dd9752203d5137840bd3dfb538008d579511b177412
size 8631 size 9504