diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index 2b02bf3700..31dab9ad48 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -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 diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index f4bf3465cb..2b33e37c7e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -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 { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt index ef611a2f4b..a151be665c 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -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 diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 64d59b221c..db7cd06170 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -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) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 1eb3840eb3..3bd73d5ed2 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index dc71d1d834..c2becf71cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -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 = { _, _ -> } + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 7ea427a8cf..a378c508f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt index c2f3b33667..9aa3ab5e02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt @@ -25,7 +25,7 @@ internal class CacheInvalidator(private val itemStatesCache: MutableList id } + fun contentType(): String = when (this) { + is Event -> content.type + is Virtual -> model.type + } + @Immutable data class Virtual( val id: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt index 67fefb665e..a059c9b275 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt index b42c2ac535..ff1bb36faf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 7e166dba97..233f51a5a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 8013ca8a65..1d2367d13f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -23,4 +23,6 @@ data class TimelineItemImageContent( val imageMeta: MediaResolver.Meta, val blurhash: String?, val aspectRatio: Float -) : TimelineItemEventContent +) : TimelineItemEventContent{ + override val type: String = "TimelineItemImageContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt index 6c647158c4..7974f188a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt index de98b22bbb..7a8edae953 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt index 6cbb0ccd08..993275e627 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt index eb79b29f79..44aeb93e3b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt index b4c1235f8f..54e95b7294 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt index 4870177a84..9cc7280b07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingModel.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt index 247cd58212..0b8e3fc0e5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt index ab0ec4fdf8..8c1afea886 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTimelineStartModel.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt index 6d023bf748..8b4fe44744 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemUnknownVirtualModel.kt @@ -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" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt index a7911867f9..d6c3529ab4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt @@ -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 +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 83abbb2e0e..aa916f1e79 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -135,7 +135,6 @@ class MessagesPresenterTest { mediaPickerProvider = PickerProvider(isInTest = true), featureFlagService = FakeFeatureFlagService(), ) - val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), room = matrixRoom, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 404e50a3bd..2d4ee3842c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -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(), diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index dc840b038f..41f117d6a4 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -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) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index f86b9952a8..f3a0ef9001 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -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(buildContext, listOf(roomMemberListCallback)) } is NavTarget.RoomMemberDetails -> { - createNode(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember))) + createNode(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId))) } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 42a392a900..8f96487583 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -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 { - 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(null) } - var error by remember { + val error = remember { mutableStateOf(null) } - - val memberCount by produceState>(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 = remember(dmMember) { + derivedStateOf { + if (dmMember != null) { + RoomDetailsType.Dm(dmMember) + } else { + RoomDetailsType.Room + } + } + } + + @Composable + private fun getMemberCount(membersState: MatrixRoomMembersState): State> { + 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, + leaveRoomWarning: MutableState, + error: MutableState, + ) = 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 + } + } } + + + + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 9b3f83d456..dbee610dba 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -117,7 +117,7 @@ fun RoomDetailsView( } if (state.roomType is RoomDetailsType.Room) { - val memberCount = (state.memberCount as? Async.Success)?.state + val memberCount = state.memberCount.dataOrNull() MembersSection( memberCount = memberCount, isLoading = state.memberCount.isLoading(), diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt index b17cb9ab6a..2cd3b7eb08 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt @@ -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) } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt similarity index 56% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt index 60b0b7cf4b..4534842cac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/testCoroutineDispatchers.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt @@ -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 +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt index aacc0113c9..5b1e7a72e0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -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, - 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() - 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, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 8841bd9d5e..8d66d60bc2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -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 { 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.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, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index f5e5bd3efb..28885006b1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -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>, val userListState: UserListState, -// val eventSink: (AddPeopleEvents) -> Unit, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index f356e203f2..99c6a9299a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -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) { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt index dc74a9a809..e0559f3caf 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt @@ -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 { - return room.members().firstOrNull().orEmpty().filter { member -> - if (query.isBlank()) { - true - } else { + override suspend fun search(query: String): List = 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( ) ) } - } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index 72e335c1d2..7fd4dd3876 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -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() - 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, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index d8b317d75a..594152e241 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -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 { 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(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) = 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) = launch { - room.unignoreUser(userId).onSuccess { isBlockedState.value = false } + client.unignoreUser(userId) + .map { + isBlockedState.value = false + room.updateMembers() + } } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index d6bf99472d..2cc6d0ec24 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -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 = 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( diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt index 373ebbb347..a5798381c0 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt @@ -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 { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt index a98ec1f971..13eb28ca85 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt @@ -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 { diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt index afe2d1ab3d..2cfd23eb61 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt @@ -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 suspend fun getProfile(userId: UserId): MatrixUser? } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt index bb74dff2a9..3be961598d 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -47,7 +47,9 @@ suspend fun (suspend () -> T).execute(state: MutableState>, errorMa } suspend fun (suspend () -> Result).executeResult(state: MutableState>) { - state.value = Async.Loading() + if (state.value !is Async.Success) { + state.value = Async.Loading() + } this().fold( onSuccess = { state.value = Async.Success(it) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt index 02758901f1..e5dcc0c5ff 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -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, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 3bf4f7d0b4..2d1e0558f4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -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 it’s 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() } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt index 7c498a610c..d897f6ad4e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt @@ -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 = {} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index eab4060d85..7bc9ba0797 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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 + suspend fun unignoreUser(userId: UserId): Result suspend fun createRoom(createRoomParams: CreateRoomParameters): Result suspend fun createDM(userId: UserId): Result fun startSync() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 4682e88bf9..35451874aa 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -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> + /** + * The current loaded members as a StateFlow. + * Initial value is [MatrixRoomMembersState.Unknown]. + * To update them you should call [updateMembers]. + */ + val membersStateFlow: StateFlow - fun updateMembers() - - fun getMember(userId: UserId): Flow - - fun getDmMember(): Flow + /** + * Try to load the room members and update the membersFlow. + */ + suspend fun updateMembers(): Result fun syncUpdateFlow(): Flow fun timeline(): MatrixTimeline - suspend fun fetchMembers(): Result - suspend fun userDisplayName(userId: UserId): Result suspend fun userAvatarUrl(userId: UserId): Result @@ -62,10 +67,6 @@ interface MatrixRoom : Closeable { suspend fun redactEvent(eventId: EventId, reason: String? = null): Result - suspend fun ignoreUser(userId: UserId): Result - - suspend fun unignoreUser(userId: UserId): Result - suspend fun leave(): Result suspend fun acceptInvitation(): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt new file mode 100644 index 0000000000..4e41fd43ba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt @@ -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? = null) : MatrixRoomMembersState + data class Error(val failure: Throwable, val prevRoomMembers: List? = null) : MatrixRoomMembersState + data class Ready(val roomMembers: List) : MatrixRoomMembersState +} + +fun MatrixRoomMembersState.roomMembers(): List? { + return when (this) { + is MatrixRoomMembersState.Ready -> roomMembers + is MatrixRoomMembersState.Pending -> prevRoomMembers + is MatrixRoomMembersState.Error -> prevRoomMembers + else -> null + } +} + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 25bb3e8a7e..3c9bd030b0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 63bd4e13a2..b710a5cf6d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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 = withContext(dispatchers.io) { + runCatching { + client.ignoreUser(userId.value) + } + } + + override suspend fun unignoreUser(userId: UserId): Result = withContext(dispatchers.io) { + runCatching { + client.unignoreUser(userId.value) + } + } + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result = withContext(dispatchers.io) { runCatching { val rustParams = RustCreateRoomParameters( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 90867ccc3f..9caba227ce 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -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, private val slidingSyncRoom: SlidingSyncRoom, private val innerRoom: Room, @@ -49,6 +50,11 @@ class RustMatrixRoom( private val coroutineDispatchers: CoroutineDispatchers, ) : MatrixRoom { + override val membersStateFlow: StateFlow + get() = _membersStateFlow + + private var _membersStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) + private val timeline by lazy { RustMatrixTimeline( matrixRoom = this, @@ -59,33 +65,6 @@ class RustMatrixRoom( ) } - private var membersFlow = MutableStateFlow>(emptyList()) - - override fun members(): Flow> { - 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 { - return membersFlow.map { members -> members.find { it.userId == userId } } - } - - override fun getDmMember(): Flow { - return membersFlow.map { members -> - if (members.size == 2 && isDirect && isEncrypted) { - members.find { it.userId != currentUserId } - } else { - null - } - } - } - override fun syncUpdateFlow(): Flow { return slidingSyncUpdateFlow .filter { @@ -148,9 +127,16 @@ class RustMatrixRoom( override val isDirect: Boolean get() = innerRoom.isDirect() - override suspend fun fetchMembers(): Result = withContext(coroutineDispatchers.io) { + override suspend fun updateMembers(): Result = 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 = withContext(coroutineDispatchers.io) { - kotlin.runCatching { + runCatching { innerRoom.acceptInvitation() } } override suspend fun rejectInvitation(): Result = withContext(coroutineDispatchers.io) { - kotlin.runCatching { + runCatching { innerRoom.rejectInvitation() } } - - override suspend fun ignoreUser(userId: UserId): Result { - return runCatching { - getRustMember(userId)?.ignore() ?: error("No member with userId $userId exists in room $roomId") - } - } - - override suspend fun unignoreUser(userId: UserId): Result { - 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 } - } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index ac43eaa965..6f64db76ef 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -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> = 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 = 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() + } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index d33bd5c939..c547ac8237 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -47,6 +47,8 @@ class FakeMatrixClient( private val notificationService: FakeNotificationService = FakeNotificationService(), ) : MatrixClient { + private var ignoreUserResult: Result = Result.success(Unit) + private var unignoreUserResult: Result = Result.success(Unit) private var createRoomResult: Result = Result.success(A_ROOM_ID) private var createDmResult: Result = 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 { + return ignoreUserResult + } + + override suspend fun unignoreUser(userId: UserId): Result { + return unignoreUserResult + } + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result { delay(100) return createRoomResult @@ -130,6 +140,14 @@ class FakeMatrixClient( createDmResult = result } + fun givenIgnoreUserResult(result: Result) { + ignoreUserResult = result + } + + fun givenUnignoreUserResult(result: Result) { + unignoreUserResult = result + } + fun givenCreateDmError(failure: Throwable?) { createDmFailure = failure } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 8e604016cf..28d6525739 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -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 = emptyList(), override val isPublic: Boolean = true, override val isDirect: Boolean = false, - private val members: List = emptyList(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { + private var ignoreResult: Result = Result.success(Unit) + private var unignoreResult: Result = Result.success(Unit) private var userDisplayNameResult = Result.success(null) private var userAvatarUrlResult = Result.success(null) + private var updateMembersResult: Result = Result.success(Unit) private var acceptInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit) - private var dmMember: RoomMember? = null - private var fetchMemberResult: Result = 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 = MutableStateFlow(MatrixRoomMembersState.Unknown) + + override suspend fun updateMembers(): Result { + return updateMembersResult + } + override fun syncUpdateFlow(): Flow { return emptyFlow() } @@ -74,18 +77,6 @@ class FakeMatrixRoom( return matrixTimeline } - override suspend fun fetchMembers(): Result { - return fetchMemberResult.also { result -> - if (result.isSuccess) { - areMembersFetched = true - } - } - } - - override fun getDmMember(): Flow { - return flowOf(dmMember) - } - override suspend fun userDisplayName(userId: UserId): Result { return userDisplayNameResult } @@ -94,20 +85,6 @@ class FakeMatrixRoom( return userAvatarUrlResult } - override fun members(): Flow> { - return fetchMemberResult.fold(onSuccess = { - flowOf(members) - }, onFailure = { - errorFlow(it) - }) - } - - override fun updateMembers() = Unit - - override fun getMember(userId: UserId): Flow { - return flowOf(members.find { it.userId == userId }) - } - override suspend fun sendMessage(message: String): Result { delay(100) return Result.success(Unit) @@ -140,10 +117,6 @@ class FakeMatrixRoom( return Result.success(Unit) } - override suspend fun ignoreUser(userId: UserId): Result = ignoreResult - - override suspend fun unignoreUser(userId: UserId): Result = unignoreResult - override suspend fun leave(): Result = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) override suspend fun acceptInvitation(): Result { isInviteAccepted = true @@ -161,12 +134,12 @@ class FakeMatrixRoom( this.leaveRoomError = throwable } - fun givenFetchMemberResult(result: Result) { - fetchMemberResult = result + fun givenRoomMembersState(state: MatrixRoomMembersState) { + membersStateFlow.value = state } - fun givenDmMember(roomMember: RoomMember) { - this.dmMember = roomMember + fun givenUpdateMembersResult(result: Result) { + updateMembersResult = result } fun givenUserDisplayNameResult(displayName: Result) { @@ -185,7 +158,6 @@ class FakeMatrixRoom( rejectInviteResult = result } - fun givenIgnoreResult(result: Result) { ignoreResult = result } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt index 2d4ab683b1..6a1a5e8bfb 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaKeyer.kt @@ -33,4 +33,7 @@ internal class MediaKeyer : Keyer { } } -private fun MediaResolver.Meta.toKey() = "${url}_${kind}" +private fun MediaResolver.Meta.toKey(): String? { + if (url.isNullOrBlank()) return null + return "${url}_${kind}" +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt new file mode 100644 index 0000000000..061ce365eb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -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 { + val roomMembersState by membersStateFlow.collectAsState() + return getRoomMember(roomMembersState = roomMembersState, userId = userId) +} + +@Composable +fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembers) { + derivedStateOf { + roomMembers?.find { + it.userId == userId + } + } + } +} + +@Composable +fun MatrixRoom.getDirectRoomMember(): State { + val roomMembersState by membersStateFlow.collectAsState() + return getDirectRoomMember(roomMembersState = roomMembersState) +} + +@Composable +fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State { + 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 + } + } + } +} + diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index 44167f906f..fda44f46d1 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -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) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt new file mode 100644 index 0000000000..1309a14cb1 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt @@ -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, +) diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 78ce1fa217..5555d6e6b3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 -size 4478 +oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b +size 4483 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 78ce1fa217..5555d6e6b3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9 -size 4478 +oid sha256:21698ff1c1f2b30ee9c3cc0c2539b35fe7cf54aac07cb0dc376d7c1a03c8814b +size 4483 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 88a70ce698..c18cc0dd08 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49aafbdb693cccd3ab7ca94e61f7a1de437766a29967dd89c4aaf5134b55c004 -size 14860 +oid sha256:db69f27f60dd9d93bb4d313741b84aa4a3ed008d229590338514c7683c0e3a11 +size 14786 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png index 665c8811ac..eb47d1cd71 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b -size 4457 +oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e +size 4490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png index 665c8811ac..eb47d1cd71 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b -size 4457 +oid sha256:fc3bf884b0425c72cafecdd4afa4e2c28064799f695962360ae4c979a3fe542e +size 4490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png index f7378511e1..94f6bbbd99 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:857fedd347931600aa5613565887384579b565efb86d5eeca6e93c13f5ff442c -size 13994 +oid sha256:6c630475e03d86195a0ebcc57bd12934b799fee956c635b30df60913cd9a3f50 +size 16032 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png index a4aa94b6e4..a64e1b5a7a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddbb6611ae83055106f7b67ec828542f8a896cafb49001ed0baef43633cc77c1 -size 8884 +oid sha256:428372bd789ff5e83ba4d837bbb0592edd8a01571728c7b284c57ddb200226ed +size 9407 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png index 317643c598..9aa01dc971 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c9a3c9f68a6627654856b03d2534ae1e4e8e600989bc3719407fbf8e17a7ab1 -size 8631 +oid sha256:718cf6e3323ceb9bbf8b4dd9752203d5137840bd3dfb538008d579511b177412 +size 9504