From 1e4c30c569c002a9170d199e5e6d60f622fa0209 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 2 Oct 2024 17:49:16 +0200 Subject: [PATCH 1/5] timeline : makes typingNotification item part of the timelineItems. --- .../messages/impl/MessagesPresenter.kt | 4 --- .../features/messages/impl/MessagesState.kt | 2 -- .../messages/impl/MessagesStateProvider.kt | 2 -- .../features/messages/impl/MessagesView.kt | 1 - .../messages/impl/di/MessagesModule.kt | 6 ++++ .../list/PinnedMessagesListPresenter.kt | 9 ++++- .../impl/timeline/TimelinePresenter.kt | 6 +++- .../messages/impl/timeline/TimelineState.kt | 4 ++- .../impl/timeline/TimelineStateProvider.kt | 4 +++ .../messages/impl/timeline/TimelineView.kt | 10 ------ .../TimelineViewMessageShieldPreview.kt | 1 - .../components/TimelineItemVirtualRow.kt | 8 +++++ .../virtual/TimelineItemVirtualFactory.kt | 2 ++ .../TimelineItemTypingNotificationModel.kt | 12 +++++++ .../typing/MessagesViewWithTypingPreview.kt | 35 ------------------- .../messages/impl/MessagesPresenterTest.kt | 7 ---- .../impl/timeline/TimelinePresenterTest.kt | 6 ++-- .../impl/timeline/TimelineViewTest.kt | 1 - .../item/virtual/VirtualTimelineItem.kt | 3 ++ .../matrix/impl/timeline/RustTimeline.kt | 5 +++ .../TypingNotificationPostProcessor.kt | 35 +++++++++++++++++++ 21 files changed, 95 insertions(+), 68 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt 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 8c0616431b..b0029fe4ee 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 @@ -46,7 +46,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent -import io.element.android.features.messages.impl.typing.TypingNotificationPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus @@ -91,7 +90,6 @@ class MessagesPresenter @AssistedInject constructor( private val composerPresenter: MessageComposerPresenter, private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter, timelinePresenterFactory: TimelinePresenter.Factory, - private val typingNotificationPresenter: TypingNotificationPresenter, private val actionListPresenterFactory: ActionListPresenter.Factory, private val customReactionPresenter: CustomReactionPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter, @@ -125,7 +123,6 @@ class MessagesPresenter @AssistedInject constructor( val composerState = composerPresenter.present() val voiceMessageComposerState = voiceMessageComposerPresenter.present() val timelineState = timelinePresenter.present() - val typingNotificationState = typingNotificationPresenter.present() val actionListState = actionListPresenter.present() val customReactionState = customReactionPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present() @@ -216,7 +213,6 @@ class MessagesPresenter @AssistedInject constructor( userEventPermissions = userEventPermissions, voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, - typingNotificationState = typingNotificationState, actionListState = actionListState, customReactionState = customReactionState, reactionSummaryState = reactionSummaryState, 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 546e558ba8..2c5bae6d3b 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 @@ -15,7 +15,6 @@ import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState -import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -33,7 +32,6 @@ data class MessagesState( val composerState: MessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, - val typingNotificationState: TypingNotificationState, val actionListState: ActionListState, val customReactionState: CustomReactionState, val reactionSummaryState: ReactionSummaryState, 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 96e55aac91..2c1a487440 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 @@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent -import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState @@ -122,7 +121,6 @@ fun aMessagesState( userEventPermissions = userEventPermissions, composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, - typingNotificationState = aTypingNotificationState(), timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, actionListState = actionListState, 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 777412e5e3..25ecbcc758 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 @@ -379,7 +379,6 @@ private fun MessagesViewContent( val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior() TimelineView( state = state.timelineState, - typingNotificationState = state.typingNotificationState, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onMessageClick = onMessageClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index 8c488d61da..ee5ce58366 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +import io.element.android.features.messages.impl.typing.TypingNotificationPresenter +import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.RoomScope @@ -25,4 +27,8 @@ interface MessagesModule { @Binds fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter + + @Binds + fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 8684c16e5a..1e557f84bd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -87,7 +89,12 @@ class PinnedMessagesListPresenter @AssistedInject constructor( userHasPermissionToSendReaction = false, isCallOngoing = false, // don't compute this value or the pin icon will be shown - pinnedEventIds = emptyList() + pinnedEventIds = emptyList(), + typingNotificationState = TypingNotificationState( + renderTypingNotifications = false, + typingMembers = persistentListOf(), + reserveSpace = false, + ) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 85f517b1b0..6a980026b2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.SendPollResponseAction @@ -70,6 +71,7 @@ class TimelinePresenter @AssistedInject constructor( private val sessionPreferencesStore: SessionPreferencesStore, private val timelineController: TimelineController, private val resolveVerifiedUserSendFailurePresenter: Presenter, + private val typingNotificationPresenter: Presenter, ) : Presenter { @AssistedFactory interface Factory { @@ -225,7 +227,8 @@ class TimelinePresenter @AssistedInject constructor( .launchIn(this) } - val timelineRoomInfo by remember { + val typingNotificationState = typingNotificationPresenter.present() + val timelineRoomInfo by remember(typingNotificationState) { derivedStateOf { TimelineRoomInfo( name = room.displayName, @@ -234,6 +237,7 @@ class TimelinePresenter @AssistedInject constructor( userHasPermissionToSendReaction = userHasPermissionToSendReaction, isCallOngoing = roomInfo?.hasRoomCall.orFalse(), pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(), + typingNotificationState = typingNotificationState, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index cd27959395..cfdf5618d0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import kotlinx.collections.immutable.ImmutableList @@ -67,5 +68,6 @@ data class TimelineRoomInfo( val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendReaction: Boolean, val isCallOngoing: Boolean, - val pinnedEventIds: List + val pinnedEventIds: List, + val typingNotificationState: TypingNotificationState, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index e0a9d660ca..fb96e75e28 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -21,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.EventId @@ -241,6 +243,7 @@ internal fun aTimelineRoomInfo( isDm: Boolean = false, userHasPermissionToSendMessage: Boolean = true, pinnedEventIds: List = emptyList(), + typingNotificationState: TypingNotificationState = aTypingNotificationState(), ) = TimelineRoomInfo( isDm = isDm, name = name, @@ -248,4 +251,5 @@ internal fun aTimelineRoomInfo( userHasPermissionToSendReaction = true, isCallOngoing = false, pinnedEventIds = pinnedEventIds, + typingNotificationState = typingNotificationState, ) 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 7abf443c04..9e383facd7 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 @@ -55,9 +55,6 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider -import io.element.android.features.messages.impl.typing.TypingNotificationState -import io.element.android.features.messages.impl.typing.TypingNotificationView -import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -73,7 +70,6 @@ import kotlin.math.abs @Composable fun TimelineView( state: TimelineState, - typingNotificationState: TypingNotificationState, onUserDataClick: (UserId) -> Unit, onLinkClick: (String) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit, @@ -131,11 +127,6 @@ fun TimelineView( reverseLayout = useReverseLayout, contentPadding = PaddingValues(vertical = 8.dp), ) { - if (state.isLive) { - item { - TypingNotificationView(state = typingNotificationState) - } - } items( items = state.timelineItems, contentType = { timelineItem -> timelineItem.contentType() }, @@ -323,7 +314,6 @@ internal fun TimelineViewPreview( ), focusedEventIndex = 0, ), - typingNotificationState = aTypingNotificationState(), onUserDataClick = {}, onLinkClick = {}, onMessageClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt index 81686ed4d2..8c4e774029 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -36,7 +36,6 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview { timelineItems = items.toImmutableList(), messageShield = messageShield, ), - typingNotificationState = aTypingNotificationState(), onUserDataClick = {}, onLinkClick = {}, onMessageClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt index 7ff428223b..9b0fada9db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -26,6 +26,8 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel +import io.element.android.features.messages.impl.typing.TypingNotificationView @Composable fun TimelineItemVirtualRow( @@ -46,9 +48,15 @@ fun TimelineItemVirtualRow( latestEventSink(TimelineEvents.LoadMore(virtual.model.direction)) } } + // Empty model trick to avoid timeline jumping during forward pagination. is TimelineItemLastForwardIndicatorModel -> { Spacer(modifier = Modifier) } + is TimelineItemTypingNotificationModel -> { + TypingNotificationView( + state = timelineRoomInfo.typingNotificationState, + ) + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt index 8a3c5e2caf..39f1d334fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem @@ -39,6 +40,7 @@ class TimelineItemVirtualFactory @Inject constructor( timestamp = inner.timestamp ) is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel + VirtualTimelineItem.TypingNotification -> TimelineItemTypingNotificationModel } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt new file mode 100644 index 0000000000..a91042e3f7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +data object TimelineItemTypingNotificationModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemTypingNotificationModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt deleted file mode 100644 index b6c08daf24..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.features.messages.impl.typing - -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.features.messages.impl.MessagesView -import io.element.android.features.messages.impl.aMessagesState -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight - -@PreviewsDayNight -@Composable -internal fun MessagesViewWithTypingPreview( - @PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState -) = ElementPreview { - MessagesView( - state = aMessagesState().copy(typingNotificationState = typingState), - onBackClick = {}, - onRoomDetailsClick = {}, - onEventClick = { false }, - onUserDataClick = {}, - onLinkClick = {}, - onPreviewAttachments = {}, - onSendLocationClick = {}, - onCreatePollClick = {}, - onJoinCallClick = {}, - onViewAllPinnedMessagesClick = {}, - ) -} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index be1b4aa7a8..ddd06f4f2f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -40,7 +40,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent -import io.element.android.features.messages.impl.typing.TypingNotificationPresenter import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter @@ -1055,11 +1054,6 @@ class MessagesPresenterTest { } } val featureFlagService = FakeFeatureFlagService() - val typingNotificationPresenter = TypingNotificationPresenter( - room = matrixRoom, - sessionPreferencesStore = sessionPreferencesStore, - ) - val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) @@ -1069,7 +1063,6 @@ class MessagesPresenterTest { composerPresenter = messageComposerPresenter, voiceMessageComposerPresenter = voiceMessageComposerPresenter, timelinePresenterFactory = timelinePresenterFactory, - typingNotificationPresenter = typingNotificationPresenter, actionListPresenterFactory = FakeActionListPresenter.Factory, customReactionPresenter = customReactionPresenter, reactionSummaryPresenter = reactionSummaryPresenter, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index a0712907cc..b82299eb9c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryC import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline @@ -503,7 +504,7 @@ import kotlin.time.Duration.Companion.seconds assertThat(state.timelineItems).isNotEmpty() } initialState.eventSink.invoke(TimelineEvents.JumpToLive) - skipItems(1) + skipItems(2) awaitItem().also { state -> // Event stays focused assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) @@ -670,7 +671,7 @@ import kotlin.time.Duration.Companion.seconds timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), ): TimelinePresenter { return TimelinePresenter( - timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), + timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(timelineItemIndexer), room = room, dispatchers = testCoroutineDispatchers(), appScope = this, @@ -682,6 +683,7 @@ import kotlin.time.Duration.Companion.seconds timelineItemIndexer = timelineItemIndexer, timelineController = TimelineController(room), resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + typingNotificationPresenter = { aTypingNotificationState() }, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 4dc61b7b80..48f242d24f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -155,7 +155,6 @@ private fun AndroidComposeTestRule.setTimel setSafeContent { TimelineView( state = state, - typingNotificationState = typingNotificationState, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onMessageClick = onMessageClick, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt index 98d6d9fc76..80d627c7ab 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -24,4 +24,7 @@ sealed interface VirtualTimelineItem { val direction: Timeline.PaginationDirection, val timestamp: Long, ) : VirtualTimelineItem + + data object TypingNotification : VirtualTimelineItem + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index e0fd82bfcb..517d83ff80 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTim import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor +import io.element.android.libraries.matrix.impl.timeline.postprocessor.TypingNotificationPostProcessor import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -121,6 +122,7 @@ class RustTimeline( private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) + private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) private val backPaginationStatus = MutableStateFlow( Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS) @@ -235,6 +237,9 @@ class RustTimeline( hasMoreToLoadForward = hasMoreToLoadForward ) } + .let { items -> + typingNotificationPostProcessor.process(items = items) + } // Keep lastForwardIndicatorsPostProcessor last .let { items -> lastForwardIndicatorsPostProcessor.process( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt new file mode 100644 index 0000000000..c9aa7e67fb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +/** + * This post processor is responsible for adding a typing notification item to the timeline items when the timeline is in live mode. + */ +class TypingNotificationPostProcessor(private val mode: Timeline.Mode) { + + fun process(items: List): List { + return if (mode == Timeline.Mode.LIVE) { + buildList { + addAll(items) + add( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("TypingNotification"), + virtual = VirtualTimelineItem.TypingNotification + ) + ) + } + } else { + items + } + } +} From b88e65a3edf26f63dbebda83906f48227f4142c4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 2 Oct 2024 17:49:51 +0200 Subject: [PATCH 2/5] timeline : fix lastOutgoingMessage after last changes --- .../features/messages/impl/timeline/TimelineState.kt | 10 +++++++++- .../features/messages/impl/timeline/TimelineView.kt | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index cfdf5618d0..d46d6a6866 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -30,8 +31,15 @@ data class TimelineState( val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val eventSink: (TimelineEvents) -> Unit, ) { - val hasAnyEvent = timelineItems.any { it is TimelineItem.Event } + val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event} as? TimelineItem.Event + val hasAnyEvent = lastTimelineEvent != null val focusedEventId = focusRequestState.eventId() + + + fun isLastOutgoingMessage(uniqueId: UniqueId): Boolean { + return lastTimelineEvent != null && lastTimelineEvent.isMine && lastTimelineEvent.id == uniqueId + } + } @Immutable 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 9e383facd7..59df0cb70d 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 @@ -136,8 +136,7 @@ fun TimelineView( timelineItem = timelineItem, timelineRoomInfo = state.timelineRoomInfo, renderReadReceipts = state.renderReadReceipts, - isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && - state.timelineItems.first().identifier() == timelineItem.identifier(), + isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, From 8b6fba8512621a9bb765f73c584324c0b5628c0f Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 2 Oct 2024 17:52:06 +0200 Subject: [PATCH 3/5] timeline : add synchronisation around timelineItemIndexer --- .../impl/timeline/TimelineItemIndexer.kt | 28 ++++++++++++++----- .../impl/timeline/TimelineItemIndexerTest.kt | 3 +- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt index 877b4ea385..2db5003f4a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt @@ -11,26 +11,39 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @SingleIn(RoomScope::class) class TimelineItemIndexer @Inject constructor() { + // This is a latch to wait for the first process call + private val firstProcessLatch = CompletableDeferred() private val timelineEventsIndexes = mutableMapOf() - fun isKnown(eventId: EventId): Boolean { - return timelineEventsIndexes.containsKey(eventId).also { - Timber.d("$eventId isKnown = $it") + private val mutex = Mutex() + + suspend fun isKnown(eventId: EventId): Boolean { + firstProcessLatch.await() + return mutex.withLock { + timelineEventsIndexes.containsKey(eventId).also { + Timber.d("$eventId isKnown = $it") + } } } - fun indexOf(eventId: EventId): Int { - return (timelineEventsIndexes[eventId] ?: -1).also { - Timber.d("indexOf $eventId= $it") + suspend fun indexOf(eventId: EventId): Int { + firstProcessLatch.await() + return mutex.withLock { + (timelineEventsIndexes[eventId] ?: -1).also { + Timber.d("indexOf $eventId= $it") + } } } - fun process(timelineItems: List) { + suspend fun process(timelineItems: List) = mutex.withLock { Timber.d("process ${timelineItems.size} items") timelineEventsIndexes.clear() timelineItems.forEachIndexed { index, timelineItem -> @@ -46,6 +59,7 @@ class TimelineItemIndexer @Inject constructor() { else -> Unit } } + firstProcessLatch.complete(Unit) } private fun processEvent(event: TimelineItem.Event, index: Int) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt index 3e20250910..2e3687b74e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt @@ -13,11 +13,12 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.test.AN_EVENT_ID +import kotlinx.coroutines.test.runTest import org.junit.Test class TimelineItemIndexerTest { @Test - fun `test TimelineItemIndexer`() { + fun `test TimelineItemIndexer`() = runTest { val eventIds = mutableListOf() val data = listOf( aTimelineItemEvent().also { eventIds.add(it.eventId!!) }, From 88e01e7c2eb1dc94b7d562bd7888795765852c1e Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 2 Oct 2024 18:44:38 +0200 Subject: [PATCH 4/5] timeline : fix jumpToBottom for not live timeline. --- .../features/messages/impl/timeline/TimelineView.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 59df0cb70d..bce3bb3f5e 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 @@ -30,9 +30,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -206,6 +208,7 @@ private fun BoxScope.TimelineScrollHelper( lazyListState.firstVisibleItemIndex < 3 && isLive } } + var jumpToLiveHandled by remember { mutableStateOf(true) } fun scrollToBottom() { coroutineScope.launch { @@ -221,10 +224,18 @@ private fun BoxScope.TimelineScrollHelper( if (isLive) { scrollToBottom() } else { + jumpToLiveHandled = false onJumpToLive() } } + LaunchedEffect(jumpToLiveHandled, isLive) { + if (!jumpToLiveHandled && isLive) { + lazyListState.scrollToItem(0) + jumpToLiveHandled = true + } + } + val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender) LaunchedEffect(focusRequestState) { if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed) { From adc03c96761eec67f1cd401d7bff0e5ebc40d8d8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 3 Oct 2024 12:49:24 +0200 Subject: [PATCH 5/5] timeline : improve jumpTo precision (introducing animateScrollToItemCenter) --- .../messages/impl/di/MessagesModule.kt | 1 - .../impl/timeline/TimelineItemIndexer.kt | 3 - .../impl/timeline/TimelinePresenter.kt | 109 +++++++++--------- .../messages/impl/timeline/TimelineState.kt | 6 +- .../messages/impl/timeline/TimelineView.kt | 10 +- .../TimelineViewMessageShieldPreview.kt | 1 - .../factories/TimelineItemsFactory.kt | 3 - .../messages/impl/MessagesPresenterTest.kt | 2 + .../fixtures/TimelineItemsFactoryFixtures.kt | 9 +- .../impl/timeline/TimelinePresenterTest.kt | 2 +- .../impl/timeline/TimelineViewTest.kt | 3 - .../designsystem/utils/LazyListState.kt | 37 ++++++ .../item/virtual/VirtualTimelineItem.kt | 1 - .../TypingNotificationPostProcessor.kt | 1 - 14 files changed, 102 insertions(+), 86 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index ee5ce58366..a6cbd68cf6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -30,5 +30,4 @@ interface MessagesModule { @Binds fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter - } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt index 2db5003f4a..2c22e714ae 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt @@ -8,8 +8,6 @@ package io.element.android.features.messages.impl.timeline import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.core.EventId import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex @@ -17,7 +15,6 @@ import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject -@SingleIn(RoomScope::class) class TimelineItemIndexer @Inject constructor() { // This is a latch to wait for the first process call private val firstProcessLatch = CompletableDeferred() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 6a980026b2..819f7a1f3a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -55,12 +55,12 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L class TimelinePresenter @AssistedInject constructor( timelineItemsFactoryCreator: TimelineItemsFactory.Creator, - private val timelineItemIndexer: TimelineItemIndexer, private val room: MatrixRoom, private val dispatchers: CoroutineDispatchers, private val appScope: CoroutineScope, @@ -70,6 +70,7 @@ class TimelinePresenter @AssistedInject constructor( private val endPollAction: EndPollAction, private val sessionPreferencesStore: SessionPreferencesStore, private val timelineController: TimelineController, + private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), private val resolveVerifiedUserSendFailurePresenter: Presenter, private val typingNotificationPresenter: Presenter, ) : Presenter { @@ -89,13 +90,7 @@ class TimelinePresenter @AssistedInject constructor( @Composable override fun present(): TimelineState { val localScope = rememberCoroutineScope() - val focusRequestState: MutableState = remember { - mutableStateOf(FocusRequestState.None) - } - - LaunchedEffect(Unit) { - timelineItemsFactory.timelineItems.collect { timelineItems = it } - } + var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) } val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } @@ -154,13 +149,13 @@ class TimelinePresenter @AssistedInject constructor( navigator.onEditPollClick(event.pollStartId) } is TimelineEvents.FocusOnEvent -> { - focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) + focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce) } is TimelineEvents.OnFocusEventRender -> { - focusRequestState.value = focusRequestState.value.onFocusEventRender() + focusRequestState = focusRequestState.onFocusEventRender() } is TimelineEvents.ClearFocusRequestState -> { - focusRequestState.value = FocusRequestState.None + focusRequestState = FocusRequestState.None } is TimelineEvents.JumpToLive -> { timelineController.focusOnLive() @@ -173,49 +168,14 @@ class TimelinePresenter @AssistedInject constructor( } } - LaunchedEffect(focusRequestState.value) { - when (val currentFocusRequestState = focusRequestState.value) { - is FocusRequestState.Requested -> { - delay(currentFocusRequestState.debounce) - if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) { - val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId) - focusRequestState.value = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index) - } else { - focusRequestState.value = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId) - } - } - is FocusRequestState.Loading -> { - val eventId = currentFocusRequestState.eventId - timelineController.focusOnEvent(eventId) - .fold( - onSuccess = { - focusRequestState.value = FocusRequestState.Success(eventId = eventId) - }, - onFailure = { - focusRequestState.value = FocusRequestState.Failure(throwable = it) - } - ) - } - else -> Unit - } - } - - LaunchedEffect(timelineItems.size) { - computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) - } - - LaunchedEffect(timelineItems.size, focusRequestState.value) { - val currentFocusRequestState = focusRequestState.value - if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.isIndexed) { - val eventId = currentFocusRequestState.eventId - if (timelineItemIndexer.isKnown(eventId)) { - val index = timelineItemIndexer.indexOf(eventId) - focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index) - } - } - } - LaunchedEffect(Unit) { + timelineItemsFactory.timelineItems + .onEach { newTimelineItems -> + timelineItemIndexer.process(newTimelineItems) + timelineItems = newTimelineItems + } + .launchIn(this) + combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> timelineItemsFactory.replaceWith( timelineItems = items, @@ -227,6 +187,47 @@ class TimelinePresenter @AssistedInject constructor( .launchIn(this) } + LaunchedEffect(focusRequestState) { + Timber.d("## focusRequestState: $focusRequestState") + when (val currentFocusRequestState = focusRequestState) { + is FocusRequestState.Requested -> { + delay(currentFocusRequestState.debounce) + if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) { + val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId) + focusRequestState = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index) + } else { + focusRequestState = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId) + } + } + is FocusRequestState.Loading -> { + val eventId = currentFocusRequestState.eventId + timelineController.focusOnEvent(eventId) + .onSuccess { + focusRequestState = FocusRequestState.Success(eventId = eventId) + } + .onFailure { + focusRequestState = FocusRequestState.Failure(it) + } + } + else -> Unit + } + } + + LaunchedEffect(timelineItems.size) { + computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) + } + + LaunchedEffect(timelineItems.size, focusRequestState) { + val currentFocusRequestState = focusRequestState + if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) { + val eventId = currentFocusRequestState.eventId + if (timelineItemIndexer.isKnown(eventId)) { + val index = timelineItemIndexer.indexOf(eventId) + focusRequestState = FocusRequestState.Success(eventId = eventId, index = index) + } + } + } + val typingNotificationState = typingNotificationPresenter.present() val timelineRoomInfo by remember(typingNotificationState) { derivedStateOf { @@ -247,7 +248,7 @@ class TimelinePresenter @AssistedInject constructor( renderReadReceipts = renderReadReceipts, newEventState = newEventState.value, isLive = isLive, - focusRequestState = focusRequestState.value, + focusRequestState = focusRequestState, messageShield = messageShield.value, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, eventSink = { handleEvents(it) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index d46d6a6866..bfb357b579 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -31,15 +31,13 @@ data class TimelineState( val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val eventSink: (TimelineEvents) -> Unit, ) { - val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event} as? TimelineItem.Event + private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event val hasAnyEvent = lastTimelineEvent != null val focusedEventId = focusRequestState.eventId() - fun isLastOutgoingMessage(uniqueId: UniqueId): Boolean { - return lastTimelineEvent != null && lastTimelineEvent.isMine && lastTimelineEvent.id == uniqueId + return isLive && lastTimelineEvent != null && lastTimelineEvent.isMine && lastTimelineEvent.id == uniqueId } - } @Immutable 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 bce3bb3f5e..0781cc3ece 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 @@ -62,12 +62,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch -import kotlin.math.abs @Composable fun TimelineView( @@ -238,12 +238,8 @@ private fun BoxScope.TimelineScrollHelper( val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender) LaunchedEffect(focusRequestState) { - if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed) { - if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) { - lazyListState.animateScrollToItem(focusRequestState.index) - } else { - lazyListState.scrollToItem(focusRequestState.index) - } + if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed && !focusRequestState.rendered) { + lazyListState.animateScrollToItemCenter(focusRequestState.index) latestOnFocusEventRender() } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt index 8c4e774029..4566bf88bc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -14,7 +14,6 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent -import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import kotlinx.collections.immutable.toImmutableList diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index f9857328cd..c507e311f9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.timeline.factories import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory @@ -36,7 +35,6 @@ class TimelineItemsFactory @AssistedInject constructor( private val dispatchers: CoroutineDispatchers, private val virtualItemFactory: TimelineItemVirtualFactory, private val timelineItemGrouper: TimelineItemGrouper, - private val timelineItemIndexer: TimelineItemIndexer, ) { @AssistedFactory interface Creator { @@ -96,7 +94,6 @@ class TimelineItemsFactory @AssistedInject constructor( } } val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() - timelineItemIndexer.process(result) this._timelineItems.emit(result) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index ddd06f4f2f..79792f4db1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter @@ -1047,6 +1048,7 @@ class MessagesPresenterTest { timelineItemIndexer = TimelineItemIndexer(), timelineController = TimelineController(matrixRoom), resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + typingNotificationPresenter = { aTypingNotificationState() }, ) val timelinePresenterFactory = object : TimelinePresenter.Factory { override fun create(navigator: MessagesNavigator): TimelinePresenter { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index c9439e0a3f..405d356edf 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -7,7 +7,6 @@ package io.element.android.features.messages.impl.fixtures -import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory @@ -40,19 +39,16 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope -internal fun TestScope.aTimelineItemsFactoryCreator( - timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), -): TimelineItemsFactory.Creator { +internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Creator { return object : TimelineItemsFactory.Creator { override fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory { - return aTimelineItemsFactory(config, timelineItemIndexer) + return aTimelineItemsFactory(config) } } } internal fun TestScope.aTimelineItemsFactory( config: TimelineItemsFactoryConfig, - timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), ): TimelineItemsFactory { val timelineEventFormatter = aTimelineEventFormatter() val matrixClient = FakeMatrixClient() @@ -96,7 +92,6 @@ internal fun TestScope.aTimelineItemsFactory( ), ), timelineItemGrouper = TimelineItemGrouper(), - timelineItemIndexer = timelineItemIndexer, config = config ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index b82299eb9c..a008feac24 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -671,7 +671,7 @@ import kotlin.time.Duration.Companion.seconds timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), ): TimelinePresenter { return TimelinePresenter( - timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(timelineItemIndexer), + timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), room = room, dispatchers = testCoroutineDispatchers(), appScope = this, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 48f242d24f..88f4edf003 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -17,8 +17,6 @@ import io.element.android.features.messages.impl.timeline.components.aCriticalSh import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel -import io.element.android.features.messages.impl.typing.TypingNotificationState -import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.Timeline @@ -139,7 +137,6 @@ class TimelineViewTest { private fun AndroidComposeTestRule.setTimelineView( state: TimelineState, - typingNotificationState: TypingNotificationState = aTypingNotificationState(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt index e793a37ef1..73bb290357 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt @@ -7,6 +7,8 @@ package io.element.android.libraries.designsystem.utils +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -35,3 +37,38 @@ fun LazyListState.isScrollingUp(): Boolean { } }.value } + +suspend fun LazyListState.animateScrollToItemCenter(index: Int) { + fun LazyListLayoutInfo.containerSize(): Int { + return if (orientation == Orientation.Vertical) { + viewportSize.height + } else { + viewportSize.width + } - beforeContentPadding - afterContentPadding + } + + fun LazyListLayoutInfo.resolveItemOffsetToCenter(index: Int): Int? { + val itemInfo = visibleItemsInfo.firstOrNull { it.index == index } ?: return null + val containerSize = containerSize() + val itemSize = itemInfo.size + return if (itemSize > containerSize) { + itemSize - containerSize / 2 + } else { + -(containerSize() - itemInfo.size) / 2 + } + } + + // await for the first layout. + scroll { } + layoutInfo.resolveItemOffsetToCenter(index)?.let { offset -> + // Item is already visible, just scroll to center. + animateScrollToItem(index, offset) + return + } + // Item is not visible, jump to it... + scrollToItem(index) + // and then adjust according to the actual item size. + layoutInfo.resolveItemOffsetToCenter(index)?.let { offset -> + animateScrollToItem(index, offset) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt index 80d627c7ab..07458e13eb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -26,5 +26,4 @@ sealed interface VirtualTimelineItem { ) : VirtualTimelineItem data object TypingNotification : VirtualTimelineItem - } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt index c9aa7e67fb..1b16027b35 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt @@ -16,7 +16,6 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime * This post processor is responsible for adding a typing notification item to the timeline items when the timeline is in live mode. */ class TypingNotificationPostProcessor(private val mode: Timeline.Mode) { - fun process(items: List): List { return if (mode == Timeline.Mode.LIVE) { buildList {