diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt index ae5ae4f8db..aa01b259de 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt @@ -16,19 +16,13 @@ package io.element.android.appnav.room -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -38,7 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView -import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -47,7 +41,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.theme.placeholderBackground import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @@ -102,20 +95,7 @@ private fun LoadingRoomTopBar( BackButton(onClick = onBackClicked) }, title = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(AvatarSize.TimelineRoom.dp) - .align(Alignment.CenterVertically) - .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) - ) - Spacer(modifier = Modifier.width(8.dp)) - PlaceholderAtom(width = 20.dp, height = 7.dp) - Spacer(modifier = Modifier.width(7.dp)) - PlaceholderAtom(width = 45.dp, height = 7.dp) - } + IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp) }, windowInsets = WindowInsets(0.dp), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index a0a3e3a286..acaaf54c9e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -24,7 +24,6 @@ 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.saveable.rememberSaveable @@ -76,6 +75,7 @@ import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber class MessagesPresenter @AssistedInject constructor( @@ -109,11 +109,13 @@ class MessagesPresenter @AssistedInject constructor( val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) - val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) { - value = room.displayName - } - val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) { - value = room.avatarData() + var roomName: Async by remember { mutableStateOf(Async.Uninitialized) } + var roomAvatar: Async by remember { mutableStateOf(Async.Uninitialized) } + LaunchedEffect(syncUpdateFlow.value) { + withContext(dispatchers.io) { + roomName = Async.Success(room.displayName) + roomAvatar = Async.Success(room.avatarData()) + } } var hasDismissedInviteDialog by rememberSaveable { mutableStateOf(false) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 8a067a3a26..a042ec1ac4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.core.RoomId @Immutable data class MessagesState( val roomId: RoomId, - val roomName: String, - val roomAvatar: AvatarData, + val roomName: Async, + val roomAvatar: Async, val userHasPermissionToSendMessage: Boolean, val composerState: MessageComposerState, val timelineState: TimelineState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index d0ddcf68f4..ce50cc138b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -38,13 +38,17 @@ open class MessagesStateProvider : PreviewParameterProvider { aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)), aMessagesState().copy(userHasPermissionToSendMessage = false), aMessagesState().copy(showReinvitePrompt = true), + aMessagesState().copy( + roomName = Async.Uninitialized, + roomAvatar = Async.Uninitialized, + ), ) } fun aMessagesState() = MessagesState( roomId = RoomId("!id:domain"), - roomName = "Room name", - roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom), + roomName = Async.Success("Room name"), + roomAvatar = Async.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), userHasPermissionToSendMessage = true, composerState = aMessageComposerState().copy( text = "Hello", diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b68b0eea7a..f909ac7f1a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -62,10 +62,12 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -135,8 +137,8 @@ fun MessagesView( Column { ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) MessagesViewTopBar( - roomTitle = state.roomName, - roomAvatar = state.roomAvatar, + roomName = state.roomName.dataOrNull(), + roomAvatar = state.roomAvatar.dataOrNull(), onBackPressed = onBackPressed, onRoomDetailsClicked = onRoomDetailsClicked, ) @@ -201,7 +203,7 @@ fun MessagesView( } @Composable -fun ReinviteDialog(state: MessagesState) { +private fun ReinviteDialog(state: MessagesState) { if (state.showReinvitePrompt) { ConfirmationDialog( title = stringResource(id = R.string.screen_room_invite_again_alert_title), @@ -238,7 +240,7 @@ private fun AttachmentStateView( } @Composable -fun MessagesViewContent( +private fun MessagesViewContent( state: MessagesState, onMessageClicked: (TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, @@ -286,9 +288,9 @@ fun MessagesViewContent( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MessagesViewTopBar( - roomTitle: String, - roomAvatar: AvatarData, +private fun MessagesViewTopBar( + roomName: String?, + roomAvatar: AvatarData?, modifier: Modifier = Modifier, onRoomDetailsClicked: () -> Unit = {}, onBackPressed: () -> Unit = {}, @@ -299,17 +301,17 @@ fun MessagesViewTopBar( BackButton(onClick = onBackPressed) }, title = { - Row( - modifier = Modifier.clickable { onRoomDetailsClicked() }, - verticalAlignment = Alignment.CenterVertically - ) { - Avatar(roomAvatar) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = roomTitle, - style = ElementTheme.typography.fontBodyLgMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + val titleModifier = Modifier.clickable { onRoomDetailsClicked() } + if (roomName != null && roomAvatar != null) { + RoomAvatarAndNameRow( + roomName = roomName, + roomAvatar = roomAvatar, + modifier = titleModifier + ) + } else { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + modifier = titleModifier ) } }, @@ -318,7 +320,28 @@ fun MessagesViewTopBar( } @Composable -fun CantSendMessageBanner( +private fun RoomAvatarAndNameRow( + roomName: String, + roomAvatar: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(roomAvatar) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = roomName, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CantSendMessageBanner( modifier: Modifier = Modifier, ) { Row( 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 2ff244621d..b47ac55d35 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 @@ -65,6 +65,8 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope @@ -132,7 +134,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -177,7 +178,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -314,7 +314,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -328,10 +327,10 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.Dismiss) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } } @@ -342,7 +341,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -419,9 +417,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -448,9 +444,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -469,9 +463,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -497,15 +489,16 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) - skipItems(1) - val loadingState = awaitItem() + val loadingState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isLoading() + }.last() assertThat(loadingState.inviteProgress.isLoading()).isTrue() - val newState = awaitItem() - assertThat(newState.inviteProgress.isFailure()).isTrue() + val failureState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isFailure() + }.last() + assertThat(failureState.inviteProgress.isFailure()).isTrue() } } @@ -532,8 +525,9 @@ class MessagesPresenterTest { }.test { // Default value assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() - skipItems(2) + skipItems(1) assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() + cancelAndIgnoreRemainingEvents() } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt new file mode 100644 index 0000000000..1a10aa2886 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.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.designsystem.atomic.molecules + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun IconTitlePlaceholdersRowMolecule( + iconSize: Dp, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + ) { + Box( + modifier = Modifier + .size(iconSize) + .align(Alignment.CenterVertically) + .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + PlaceholderAtom(width = 20.dp, height = 7.dp) + Spacer(modifier = Modifier.width(7.dp)) + PlaceholderAtom(width = 45.dp, height = 7.dp) + } +} + +@DayNightPreviews +@Composable +internal fun IconTitlePlaceholdersRowMoleculePreview() = ElementPreview { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + ) +} 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 640e0772a9..6bfe37045f 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 @@ -150,7 +150,7 @@ class RustMatrixClient constructor( }.launchIn(sessionCoroutineScope) } - override suspend fun getRoom(roomId: RoomId): MatrixRoom? { + override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) { // Check if already in memory... var cachedPairOfRoom = pairOfRoom(roomId) if (cachedPairOfRoom == null) { @@ -158,24 +158,24 @@ class RustMatrixClient constructor( roomSummaryDataSource.awaitAllRoomsAreLoaded() cachedPairOfRoom = pairOfRoom(roomId) } - if (cachedPairOfRoom == null) return null - val (roomListItem, fullRoom) = cachedPairOfRoom - return RustMatrixRoom( - sessionId = sessionId, - roomListItem = roomListItem, - innerRoom = fullRoom, - sessionCoroutineScope = sessionCoroutineScope, - coroutineDispatchers = dispatchers, - systemClock = clock, - roomContentForwarder = roomContentForwarder, - sessionData = sessionStore.getSession(sessionId.value)!!, - ) + cachedPairOfRoom?.let { (roomListItem, fullRoom) -> + RustMatrixRoom( + sessionId = sessionId, + roomListItem = roomListItem, + innerRoom = fullRoom, + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = dispatchers, + systemClock = clock, + roomContentForwarder = roomContentForwarder, + sessionData = sessionStore.getSession(sessionId.value)!!, + ) + } } - private suspend fun pairOfRoom(roomId: RoomId): Pair? = withContext(sessionDispatcher) { + private fun pairOfRoom(roomId: RoomId): Pair? { val cachedRoomListItem = roomListService.roomOrNull(roomId.value) val fullRoom = cachedRoomListItem?.fullRoom() - if (cachedRoomListItem == null || fullRoom == null) { + return if (cachedRoomListItem == null || fullRoom == null) { Timber.d("No room cached for $roomId") null } else { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt index d6febb32dc..29b75a1dca 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -16,28 +16,45 @@ package io.element.android.libraries.matrix.impl.timeline +import io.element.android.libraries.matrix.impl.util.destroyAll import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow import org.matrix.rustcomponents.sdk.BackPaginationStatus import org.matrix.rustcomponents.sdk.BackPaginationStatusListener import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomTimelineListenerResult import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineListener +import timber.log.Timber internal fun Room.timelineDiffFlow(onInitialList: suspend (List) -> Unit): Flow = - mxCallbackFlow { + callbackFlow { + val roomId = id() + Timber.d("Open timelineDiffFlow for room $roomId") val listener = object : TimelineListener { override fun onUpdate(diff: TimelineDiff) { trySendBlocking(diff) } } - val result = addTimelineListener(listener) - onInitialList(result.items) - result.itemsStream + var result: RoomTimelineListenerResult? = null + try { + result = addTimelineListener(listener) + onInitialList(result.items) + } catch (exception: Exception) { + Timber.d(exception, "Catch failure in timelineDiffFlow of room $roomId") + } + awaitClose { + Timber.d("Close timelineDiffFlow for room $roomId") + result?.itemsStream?.cancel() + result?.itemsStream?.destroy() + result?.items?.destroyAll() + } }.buffer(Channel.UNLIMITED) internal fun Room.backPaginationStatusFlow(): Flow = 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 d9b6604170..d556d8aab9 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 @@ -32,6 +32,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -131,9 +133,10 @@ class RustMatrixTimeline( encryptedHistoryPostProcessor.process(items) } - private suspend fun postItems(items: List) { + private suspend fun postItems(items: List) = coroutineScope { // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap. items.chunked(INITIAL_MAX_SIZE).reversed().forEach { + ensureActive() timelineDiffProcessor.postItems(it) } isInit.set(true) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt new file mode 100644 index 0000000000..ac92a2e026 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt @@ -0,0 +1,24 @@ +/* + * 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.impl.util + +import org.matrix.rustcomponents.sdk.Disposable + +/** + * Call destroy on all elements of the iterable. + */ +internal fun Iterable.destroyAll() = forEach { it.destroy() } diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index d7c17c7895..184bbc418a 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { implementation(libs.test.junit) implementation(libs.coroutines.test) implementation(projects.libraries.core) + implementation(libs.test.turbine) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt new file mode 100644 index 0000000000..57aaa7dbfc --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt @@ -0,0 +1,59 @@ +/* + * 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.tests.testutils + +import app.cash.turbine.Event +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.withTurbineTimeout +import io.element.android.libraries.core.data.tryOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Consume all items until timeout is reached waiting for an event or we receive terminal event. + * The timeout is applied for each event. + * @return the list of consumed items. + */ +suspend fun ReceiveTurbine.consumeItemsUntilTimeout(timeout: Duration = 100.milliseconds): List { + return consumeItemsUntilPredicate(timeout) { false } +} + +/** + * Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event. + * The timeout is applied for each event. + * @return the list of consumed items. + */ +suspend fun ReceiveTurbine.consumeItemsUntilPredicate( + timeout: Duration = 100.milliseconds, + predicate: (T) -> Boolean, +): List { + val items = ArrayList() + tryOrNull { + while (true) { + when (val event = withTurbineTimeout(timeout) { awaitEvent() }) { + is Event.Item -> { + items.add(event.value) + if (predicate(event.value)) { + break + } + } + else -> break + } + } + } + return items +} diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_5,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_null_DefaultGroup_MessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f5df0bc94e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5f6af4e49ea68ef43a7342910d5f6c72981044ad776ea484adc60fb5578a179 +size 49863 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_5,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_null_DefaultGroup_MessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b7a9808bc0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26aef777d4601de18948ace8c85d5935eb8e008d07b4f3a09f40a416164bbc44 +size 51602 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-D_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.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..08f5a24c49 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebb2d33b0f8aa473b9deb44e57df4edec1346f164098eec87163ebd5c8db9ebd +size 5314 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-N_1_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.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..52309e0c28 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitlePlaceholdersRowMoleculePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b71348d0a316569c389682f5904c2d5b67180bd6c4ae13112d81afdb6cdada6 +size 5273