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 androidx.startup.AppInitializer
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.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer
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.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -103,7 +102,7 @@ class RoomFlowNode @AssistedInject constructor(
private fun fetchRoomMembers() = lifecycleScope.launch {
val room = inputs.room
room.fetchMembers()
room.updateMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.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.navmodel.backstack.activeElement
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.google.common.truth.Truth
import io.element.android.features.messages.api.MessagesEntryPoint
@ -81,19 +80,6 @@ class RoomFlowNodeTest {
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
fun `given a room flow node when initialized then it loads messages entry point`() {
// GIVEN

View file

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

View file

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

View file

@ -102,6 +102,7 @@ private fun SheetContent(
// Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp))
}
is ActionListState.Target.Success -> {
val actions = target.actions
LazyColumn(
@ -146,5 +147,11 @@ fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) st
@Composable
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(
items = state.timelineItems,
contentType = { _, timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.key() },
key = { _, timelineItem -> timelineItem.identifier() },
) { index, timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
isHighlighted = timelineItem.identifier() == state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked
)
@ -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
fun TimelineItemRow(
timelineItem: TimelineItem,

View file

@ -25,7 +25,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
ListUpdateCallback {
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 {
// Invalidate cache
itemStatesCache[it] = null
@ -33,13 +33,13 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
}
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)
itemStatesCache.add(toPosition, model)
}
override fun onInserted(position: Int, count: Int) {
Timber.v("onInserted(position= $position, count= $count")
Timber.d("onInserted(position= $position, count= $count)")
itemStatesCache.invalidateLast()
repeat(count) {
itemStatesCache.add(position, null)
@ -47,7 +47,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList<Timelin
}
override fun onRemoved(position: Int, count: Int) {
Timber.v("onRemoved(position= $position, count= $count")
Timber.d("onRemoved(position= $position, count= $count)")
itemStatesCache.invalidateLast()
repeat(count) {
itemStatesCache.removeAt(position)

View file

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

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
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(
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
@Immutable
sealed interface TimelineItemEventContent
sealed interface TimelineItemEventContent {
val type: String
}

View file

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

View file

@ -21,4 +21,6 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent(
override val body: String,
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
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(
override val body: String,
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
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(
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
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
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
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
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
@Immutable
sealed interface TimelineItemVirtualModel
sealed interface TimelineItemVirtualModel {
val type: String
}

View file

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

View file

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

View file

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

View file

@ -59,7 +59,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
object RoomMemberList : NavTarget
@Parcelize
data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -74,14 +74,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
}
NavTarget.RoomMemberList -> {
val roomMemberListCallback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMember: RoomMember) {
backstack.push(NavTarget.RoomMemberDetails(roomMember))
override fun openRoomMemberDetails(roomMemberId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
}
}
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
}
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
) : Presenter<RoomDetailsState> {
private val roomMemberDetailsPresenter by lazy {
val dmMember = runBlocking {
room.getDmMember().firstOrNull()
}
if (dmMember != null) {
RoomMemberDetailsPresenter(matrixClient.sessionId, room, dmMember)
} else {
null
}
}
@Composable
override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember {
val leaveRoomWarning = remember {
mutableStateOf<LeaveRoomWarning?>(null)
}
var error by remember {
val error = remember {
mutableStateOf<RoomDetailsError?>(null)
}
val memberCount by produceState<Async<Int>>(initialValue = Async.Loading(null)) {
room.members().map { it.count() }
.onEach { value = Async.Success(it) }
.catch { value = Async.Failure(it) }
.launchIn(coroutineScope)
LaunchedEffect(Unit) {
room.updateMembers()
}
val dmMember by room.getDmMember().collectAsState(initial = null)
val roomType = if (dmMember != null) {
RoomDetailsType.Dm(dmMember!!)
} else {
RoomDetailsType.Room
}
val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState)
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType = getRoomType(dmMember)
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
is RoomDetailsEvent.LeaveRoom -> {
if (event.needsConfirmation) {
leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
} else {
coroutineScope.launch(Dispatchers.IO) {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error = RoomDetailsError.AlertGeneric
}
leaveRoomWarning = null
}
}
coroutineScope.leaveRoom(
needsConfirmation = event.needsConfirmation,
memberCount = memberCount,
leaveRoomWarning = leaveRoomWarning,
error = error,
)
}
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null
RoomDetailsEvent.ClearError -> error = null
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error.value = null
}
}
val roomMemberDetailsState = if (dmMember != null) {
roomMemberDetailsPresenter?.present()
} else {
null
}
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
return RoomDetailsState(
roomId = room.roomId.value,
@ -118,11 +90,66 @@ class RoomDetailsPresenter @Inject constructor(
roomTopic = room.topic,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
displayLeaveRoomWarning = leaveRoomWarning,
error = error,
roomType = roomType,
displayLeaveRoomWarning = leaveRoomWarning.value,
error = error.value,
roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState,
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) {
val memberCount = (state.memberCount as? Async.Success<Int>)?.state
val memberCount = state.memberCount.dataOrNull()
MembersSection(
memberCount = memberCount,
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.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import javax.inject.Named
@ -48,8 +49,8 @@ object RoomMemberProvidesModule {
room: MatrixRoom,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient.sessionId, room, roomMember)
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
}
}
}

View file

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

View file

@ -26,35 +26,25 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.core.UserId
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)
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val room: MatrixRoom,
private val presenter: RoomMemberListPresenter,
private val coroutineScope: CoroutineScope,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberDetails(roomMember: RoomMember)
fun openRoomMemberDetails(roomMemberId: UserId)
}
private val callbacks = plugins<Callback>()
private fun onUserSelected(matrixUser: MatrixUser) = coroutineScope.launch {
val member = room.getMember(matrixUser.id).firstOrNull()
if (member != null) {
callbacks.forEach { it.openRoomMemberDetails(member) }
} else {
Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}")
private fun openRoomMemberDetails(roomMemberId: UserId) {
callbacks.forEach {
it.openRoomMemberDetails(roomMemberId)
}
}
@ -65,7 +55,7 @@ class RoomMemberListNode @AssistedInject constructor(
state = state,
modifier = modifier,
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.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
@ -39,6 +40,8 @@ class RoomMemberListPresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("RoomMembers") private val userListDataSource: UserListDataSource,
private val userListDataStore: UserListDataStore,
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy {
@ -53,14 +56,16 @@ class RoomMemberListPresenter @Inject constructor(
override fun present(): RoomMemberListState {
val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withContext(coroutineDispatchers.io) {
allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
}
}
return RoomMemberListState(
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.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
)

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.Scaffold
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
@OptIn(ExperimentalMaterial3Api::class)
@ -59,8 +60,13 @@ fun RoomMemberListView(
state: RoomMemberListState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onMemberSelected: (UserId) -> Unit = {},
) {
fun onUserSelected(user: MatrixUser) {
onMemberSelected(user.id)
}
Scaffold(
topBar = {
if (!state.userListState.isSearchActive) {
@ -76,7 +82,7 @@ fun RoomMemberListView(
) {
UserListView(
state = state.userListState,
onUserSelected = onUserSelected,
onUserSelected = ::onUserSelected,
)
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.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomUserListDataSource @Inject constructor(
private val room: MatrixRoom
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return room.members().firstOrNull().orEmpty().filter { member ->
if (query.isBlank()) {
true
} else {
override suspend fun search(query: String): List<MatrixUser> = withContext(coroutineDispatchers.io) {
val roomMembers = room.membersStateFlow
.dropWhile { it !is MatrixRoomMembersState.Ready }
.first()
.roomMembers()
.orEmpty()
val filteredMembers = if (query.isBlank()) {
roomMembers
} else {
roomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true)
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}.map(::mapMemberToMatrixUser)
}
filteredMembers.map(::mapMemberToMatrixUser)
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
@ -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.inputs
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.room.RoomMember
import timber.log.Timber
import io.element.android.libraries.androidutils.R as AndroidUtilsR
@ -43,18 +43,18 @@ class RoomMemberDetailsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val member: RoomMember,
val roomMemberId: UserId,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.member)
private val presenter = presenterFactory.create(inputs.roomMemberId)
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.member.userId)
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -28,28 +29,36 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
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.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.launch
class RoomMemberDetailsPresenter @AssistedInject constructor(
private val currentUserSessionId: SessionId,
private val client: MatrixClient,
private val room: MatrixRoom,
@Assisted private val roomMember: RoomMember,
@Assisted private val roomMemberId: UserId,
) : Presenter<RoomMemberDetailsState> {
interface Factory {
fun create(roomMember: RoomMember): RoomMemberDetailsPresenter
fun create(roomMemberId: UserId): RoomMemberDetailsPresenter
}
@Composable
override fun present(): RoomMemberDetailsState {
val coroutineScope = rememberCoroutineScope()
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) {
when (event) {
@ -58,7 +67,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
coroutineScope.blockUser(roomMember.userId, isBlocked)
coroutineScope.blockUser(roomMemberId, isBlocked)
}
}
is RoomMemberDetailsEvents.UnblockUser -> {
@ -66,41 +75,50 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
coroutineScope.unblockUser(roomMember.userId, isBlocked)
coroutineScope.unblockUser(roomMemberId, isBlocked)
}
}
RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
}
}
val userName by produceState(initialValue = roomMember.displayName) {
room.userDisplayName(roomMember.userId).onSuccess { displayName ->
val userName by produceState(initialValue = roomMember?.displayName) {
room.userDisplayName(roomMemberId).onSuccess { displayName ->
if (displayName != null) value = displayName
}
}
val userAvatar by produceState(initialValue = roomMember.avatarUrl) {
room.userAvatarUrl(roomMember.userId).onSuccess { avatarUrl ->
val userAvatar by produceState(initialValue = roomMember?.avatarUrl) {
room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl ->
if (avatarUrl != null) value = avatarUrl
}
}
return RoomMemberDetailsState(
userId = roomMember.userId.value,
userId = roomMemberId.value,
userName = userName,
avatarUrl = userAvatar,
isBlocked = isBlocked.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = roomMember.userId == currentUserSessionId,
isCurrentUser = roomMember?.userId == client.sessionId,
eventSink = ::handleEvents
)
}
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 {
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.RoomDetailsPresenter
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.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
@ -47,11 +54,21 @@ import org.junit.Test
class RoomDetailsPresenterTests {
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
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -60,7 +77,7 @@ class RoomDetailsPresenterTests {
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
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)
cancelAndIgnoreRemainingEvents()
@ -69,23 +86,42 @@ class RoomDetailsPresenterTests {
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val error = RuntimeException()
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) {
presenter.present()
}.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null))
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
val finalState = awaitItem()
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0))
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
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
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -98,36 +134,21 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with DM member sets custom DM roomType`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenDmMember(aRoomMember())
val myRoomMember = aRoomMember(A_SESSION_ID)
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) {
presenter.present()
}.test {
val initialState = awaitItem()
// It's not configured yet in the first iteration
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)
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
cancelAndIgnoreRemainingEvents()
}
@ -135,15 +156,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val room = aMatrixRoom(isPublic = false).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
@ -152,15 +172,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember()))
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
@ -169,15 +188,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
@ -186,15 +204,14 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val room = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents()
@ -211,14 +228,11 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable())
}
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull()
@ -228,24 +242,28 @@ class RoomDetailsPresenterTests {
}
}
fun aMatrixClient(
sessionId: SessionId = A_SESSION_ID,
) = FakeMatrixClient()
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
displayName: String = "A fallback display name",
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
) = FakeMatrixRoom(
roomId = roomId,
name = name,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,
members = members,
isEncrypted = isEncrypted,
isPublic = isPublic,
isDirect = isDirect,
)
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.test.FakeUserListDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList
import org.junit.Test
@ -38,6 +42,8 @@ import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberListPresenterTests {
private val testCoroutineDispatchers = testCoroutineDispatchers()
@Test
fun `present - search is done automatically on start, but is async`() = runTest {
val searchResult = listOf(aMatrixUser())
@ -52,7 +58,14 @@ class RoomMemberListPresenterTests {
userListDataStore: UserListDataStore,
) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
}
val presenter = RoomMemberListPresenter(userListFactory, userListDataSource, userListDataStore)
val fakeRoom = FakeMatrixRoom()
val presenter = RoomMemberListPresenter(
userListPresenterFactory = userListFactory,
userListDataSource = userListDataSource,
userListDataStore = userListDataStore,
room = fakeRoom,
coroutineDispatchers = testCoroutineDispatchers
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {

View file

@ -20,12 +20,13 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixClient
import io.element.android.features.roomdetails.aMatrixRoom
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.RoomMemberDetailsPresenter
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.test.runTest
import org.junit.Test
@ -33,14 +34,17 @@ import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberDetailsPresenterTests {
private val matrixClient = aMatrixClient()
@Test
fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success("A custom name"))
givenUserAvatarUrlResult(Result.success("A custom avatar"))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -58,12 +62,13 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - will recover when retrieving room member details fails`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.failure(Throwable()))
givenUserAvatarUrlResult(Result.failure(Throwable()))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -77,12 +82,13 @@ class RoomMemberDetailsPresenterTests {
@Test
fun `present - will fallback to original data if the updated data is null`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
val room = aMatrixRoom().apply {
givenUserDisplayNameResult(Result.success(null))
givenUserAvatarUrlResult(Result.success(null))
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -98,7 +104,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -119,7 +125,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -136,7 +142,7 @@ class RoomMemberDetailsPresenterTests {
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {

View file

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

View file

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

View file

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

View file

@ -30,8 +30,37 @@ data class AvatarData(
@IgnoredOnParcel
val size: AvatarSize = AvatarSize.MEDIUM
) : Parcelable {
fun getInitial(): String {
val firstChar = name?.firstOrNull() ?: id.getOrNull(1) ?: '?'
return firstChar.uppercase()
@IgnoredOnParcel
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.layout.ColumnScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetDefaults
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.contentColorFor
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -44,7 +44,7 @@ fun ModalBottomSheetLayout(
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
content: @Composable () -> Unit = {}

View file

@ -35,6 +35,8 @@ interface MatrixClient : Closeable {
val invitesDataSource: RoomSummaryDataSource
fun getRoom(roomId: RoomId): 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 createDM(userId: UserId): Result<RoomId>
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.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.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
interface MatrixRoom : Closeable {
val sessionId: SessionId
val roomId: RoomId
val name: String?
val bestName: String
@ -36,20 +39,22 @@ interface MatrixRoom : Closeable {
val isDirect: 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()
fun getMember(userId: UserId): Flow<RoomMember?>
fun getDmMember(): Flow<RoomMember?>
/**
* Try to load the room members and update the membersFlow.
*/
suspend fun updateMembers(): Result<Unit>
fun syncUpdateFlow(): Flow<Long>
fun timeline(): MatrixTimeline
suspend fun fetchMembers(): Result<Unit>
suspend fun userDisplayName(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 ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun leave(): 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
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class RoomMember(
val userId: UserId,
val displayName: String?,
@ -30,7 +27,7 @@ data class RoomMember(
val powerLevel: Long,
val normalizedPowerLevel: Long,
val isIgnored: Boolean,
) : Parcelable
)
enum class RoomMembershipState {
BAN, INVITE, JOIN, KNOCK, LEAVE

View file

@ -198,7 +198,7 @@ class RustMatrixClient constructor(
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
return RustMatrixRoom(
currentUserId = sessionId,
sessionId = sessionId,
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
slidingSyncRoom = slidingSyncRoom,
innerRoom = fullRoom,
@ -212,6 +212,18 @@ class RustMatrixClient constructor(
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) {
runCatching {
val rustParams = RustCreateRoomParameters(

View file

@ -17,17 +17,19 @@
package io.element.android.libraries.matrix.impl.room
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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
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.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
class RustMatrixRoom(
private val currentUserId: UserId,
override val sessionId: SessionId,
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
private val slidingSyncRoom: SlidingSyncRoom,
private val innerRoom: Room,
@ -49,6 +50,11 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
get() = _membersStateFlow
private var _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
private val timeline by lazy {
RustMatrixTimeline(
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> {
return slidingSyncUpdateFlow
.filter {
@ -148,9 +127,16 @@ class RustMatrixRoom(
override val isDirect: Boolean
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 {
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) {
kotlin.runCatching {
runCatching {
innerRoom.acceptInvitation()
}
}
override suspend fun rejectInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
kotlin.runCatching {
runCatching {
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.TimelineListener
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
class RustMatrixTimeline(
private val matrixRoom: MatrixRoom,
@ -51,6 +52,8 @@ class RustMatrixTimeline(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixTimeline {
private val isInit = AtomicBoolean(false)
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
MutableStateFlow(emptyList())
@ -95,6 +98,7 @@ class RustMatrixTimeline(
withContext(coroutineDispatchers.diffUpdateDispatcher) {
this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems
}
isInit.set(true)
}
.onFailure {
Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})")
@ -105,6 +109,7 @@ class RustMatrixTimeline(
override fun dispose() {
Timber.v("Dispose timeline for room ${matrixRoom.roomId}")
listenerTokens.dispose()
isInit.set(false)
}
/**
@ -125,6 +130,9 @@ class RustMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
if (!isInit.get()) {
throw IllegalStateException("Timeline is not init yet")
}
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort()
@ -141,15 +149,24 @@ class RustMatrixTimeline(
runCatching {
val settings = RoomSubscription(
requiredState = listOf(
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.join_rules", value = ""),
),
timelineLimit = null
)
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
launch {
fetchMembers()
}
listenerTokens += result.taskHandle
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(),
) : 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 createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null
@ -62,6 +64,14 @@ class FakeMatrixClient(
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> {
delay(100)
return createRoomResult
@ -130,6 +140,14 @@ class FakeMatrixClient(
createDmResult = result
}
fun givenIgnoreUserResult(result: Result<Unit>) {
ignoreUserResult = result
}
fun givenUnignoreUserResult(result: Result<Unit>) {
unignoreUserResult = result
}
fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure
}

View file

@ -16,21 +16,23 @@
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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID,
override val roomId: RoomId = A_ROOM_ID,
override val name: String? = null,
override val bestName: String = "",
@ -42,21 +44,16 @@ class FakeMatrixRoom(
override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true,
override val isDirect: Boolean = false,
private val members: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : 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 userAvatarUrlResult = Result.success<String?>(null)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = 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
private set
@ -66,6 +63,12 @@ class FakeMatrixRoom(
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> {
return emptyFlow()
}
@ -74,18 +77,6 @@ class FakeMatrixRoom(
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?> {
return userDisplayNameResult
}
@ -94,20 +85,6 @@ class FakeMatrixRoom(
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> {
delay(100)
return Result.success(Unit)
@ -140,10 +117,6 @@ class FakeMatrixRoom(
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 acceptInvitation(): Result<Unit> {
isInviteAccepted = true
@ -161,12 +134,12 @@ class FakeMatrixRoom(
this.leaveRoomError = throwable
}
fun givenFetchMemberResult(result: Result<Unit>) {
fetchMemberResult = result
fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state
}
fun givenDmMember(roomMember: RoomMember) {
this.dmMember = roomMember
fun givenUpdateMembersResult(result: Result<Unit>) {
updateMembersResult = result
}
fun givenUserDisplayNameResult(displayName: Result<String?>) {
@ -185,7 +158,6 @@ class FakeMatrixRoom(
rejectInviteResult = result
}
fun givenIgnoreResult(result: Result<Unit>) {
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(projects.libraries.matrix.test)
implementation(projects.services.appnavstate.test)
implementation(projects.services.appnavstate.test)
implementation(projects.libraries.core)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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