Merge pull request #3597 from element-hq/feature/fga/timeline_better_jump_to_behaviours

Timeline better jump to behaviours
This commit is contained in:
ganfra 2024-10-03 17:16:16 +02:00 committed by GitHub
commit c274665440
26 changed files with 232 additions and 156 deletions

View file

@ -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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.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.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
@ -91,7 +90,6 @@ class MessagesPresenter @AssistedInject constructor(
private val composerPresenter: MessageComposerPresenter, private val composerPresenter: MessageComposerPresenter,
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter, private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
timelinePresenterFactory: TimelinePresenter.Factory, timelinePresenterFactory: TimelinePresenter.Factory,
private val typingNotificationPresenter: TypingNotificationPresenter,
private val actionListPresenterFactory: ActionListPresenter.Factory, private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
@ -125,7 +123,6 @@ class MessagesPresenter @AssistedInject constructor(
val composerState = composerPresenter.present() val composerState = composerPresenter.present()
val voiceMessageComposerState = voiceMessageComposerPresenter.present() val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present() val timelineState = timelinePresenter.present()
val typingNotificationState = typingNotificationPresenter.present()
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
@ -216,7 +213,6 @@ class MessagesPresenter @AssistedInject constructor(
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState, timelineState = timelineState,
typingNotificationState = typingNotificationState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,

View file

@ -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.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState 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.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.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -33,7 +32,6 @@ data class MessagesState(
val composerState: MessageComposerState, val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
val typingNotificationState: TypingNotificationState,
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,

View file

@ -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.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
@ -122,7 +121,6 @@ fun aMessagesState(
userEventPermissions = userEventPermissions, userEventPermissions = userEventPermissions,
composerState = composerState, composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(),
timelineState = timelineState, timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState, actionListState = actionListState,

View file

@ -379,7 +379,6 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior() val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
typingNotificationState = state.typingNotificationState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,

View file

@ -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.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter 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.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.architecture.Presenter
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -25,4 +27,7 @@ interface MessagesModule {
@Binds @Binds
fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter<ResolveVerifiedUserSendFailureState> fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter<ResolveVerifiedUserSendFailureState>
@Binds
fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter<TypingNotificationState>
} }

View file

@ -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.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig 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.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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -87,7 +89,12 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
userHasPermissionToSendReaction = false, userHasPermissionToSendReaction = false,
isCallOngoing = false, isCallOngoing = false,
// don't compute this value or the pin icon will be shown // don't compute this value or the pin icon will be shown
pinnedEventIds = emptyList() pinnedEventIds = emptyList(),
typingNotificationState = TypingNotificationState(
renderTypingNotifications = false,
typingMembers = persistentListOf(),
reserveSpace = false,
)
) )
} }

View file

@ -8,29 +8,39 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemIndexer @Inject constructor() { class TimelineItemIndexer @Inject constructor() {
// This is a latch to wait for the first process call
private val firstProcessLatch = CompletableDeferred<Unit>()
private val timelineEventsIndexes = mutableMapOf<EventId, Int>() private val timelineEventsIndexes = mutableMapOf<EventId, Int>()
fun isKnown(eventId: EventId): Boolean { private val mutex = Mutex()
return timelineEventsIndexes.containsKey(eventId).also {
Timber.d("$eventId isKnown = $it") 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 { suspend fun indexOf(eventId: EventId): Int {
return (timelineEventsIndexes[eventId] ?: -1).also { firstProcessLatch.await()
Timber.d("indexOf $eventId= $it") return mutex.withLock {
(timelineEventsIndexes[eventId] ?: -1).also {
Timber.d("indexOf $eventId= $it")
}
} }
} }
fun process(timelineItems: List<TimelineItem>) { suspend fun process(timelineItems: List<TimelineItem>) = mutex.withLock {
Timber.d("process ${timelineItems.size} items") Timber.d("process ${timelineItems.size} items")
timelineEventsIndexes.clear() timelineEventsIndexes.clear()
timelineItems.forEachIndexed { index, timelineItem -> timelineItems.forEachIndexed { index, timelineItem ->
@ -46,6 +56,7 @@ class TimelineItemIndexer @Inject constructor() {
else -> Unit else -> Unit
} }
} }
firstProcessLatch.complete(Unit)
} }
private fun processEvent(event: TimelineItem.Event, index: Int) { private fun processEvent(event: TimelineItem.Event, index: Int) {

View file

@ -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.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
@ -54,12 +55,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L
class TimelinePresenter @AssistedInject constructor( class TimelinePresenter @AssistedInject constructor(
timelineItemsFactoryCreator: TimelineItemsFactory.Creator, timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineItemIndexer: TimelineItemIndexer,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
@ -69,7 +70,9 @@ class TimelinePresenter @AssistedInject constructor(
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController, private val timelineController: TimelineController,
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>, private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -87,13 +90,7 @@ class TimelinePresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val focusRequestState: MutableState<FocusRequestState> = remember { var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
mutableStateOf(FocusRequestState.None)
}
LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems.collect { timelineItems = it }
}
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
@ -152,13 +149,13 @@ class TimelinePresenter @AssistedInject constructor(
navigator.onEditPollClick(event.pollStartId) navigator.onEditPollClick(event.pollStartId)
} }
is TimelineEvents.FocusOnEvent -> { is TimelineEvents.FocusOnEvent -> {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce)
} }
is TimelineEvents.OnFocusEventRender -> { is TimelineEvents.OnFocusEventRender -> {
focusRequestState.value = focusRequestState.value.onFocusEventRender() focusRequestState = focusRequestState.onFocusEventRender()
} }
is TimelineEvents.ClearFocusRequestState -> { is TimelineEvents.ClearFocusRequestState -> {
focusRequestState.value = FocusRequestState.None focusRequestState = FocusRequestState.None
} }
is TimelineEvents.JumpToLive -> { is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive() timelineController.focusOnLive()
@ -171,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) { LaunchedEffect(Unit) {
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems
}
.launchIn(this)
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith( timelineItemsFactory.replaceWith(
timelineItems = items, timelineItems = items,
@ -225,7 +187,49 @@ class TimelinePresenter @AssistedInject constructor(
.launchIn(this) .launchIn(this)
} }
val timelineRoomInfo by remember { 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 { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
name = room.displayName, name = room.displayName,
@ -234,6 +238,7 @@ class TimelinePresenter @AssistedInject constructor(
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(), isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(), pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
typingNotificationState = typingNotificationState,
) )
} }
} }
@ -243,7 +248,7 @@ class TimelinePresenter @AssistedInject constructor(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value, newEventState = newEventState.value,
isLive = isLive, isLive = isLive,
focusRequestState = focusRequestState.value, focusRequestState = focusRequestState,
messageShield = messageShield.value, messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -11,7 +11,9 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState 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.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration import kotlin.time.Duration
@ -29,8 +31,13 @@ data class TimelineState(
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val eventSink: (TimelineEvents) -> Unit, val eventSink: (TimelineEvents) -> Unit,
) { ) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event } private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
val hasAnyEvent = lastTimelineEvent != null
val focusedEventId = focusRequestState.eventId() val focusedEventId = focusRequestState.eventId()
fun isLastOutgoingMessage(uniqueId: UniqueId): Boolean {
return isLive && lastTimelineEvent != null && lastTimelineEvent.isMine && lastTimelineEvent.id == uniqueId
}
} }
@Immutable @Immutable
@ -67,5 +74,6 @@ data class TimelineRoomInfo(
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean, val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean, val isCallOngoing: Boolean,
val pinnedEventIds: List<EventId> val pinnedEventIds: List<EventId>,
val typingNotificationState: TypingNotificationState,
) )

View file

@ -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.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -241,6 +243,7 @@ internal fun aTimelineRoomInfo(
isDm: Boolean = false, isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true, userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(), pinnedEventIds: List<EventId> = emptyList(),
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
) = TimelineRoomInfo( ) = TimelineRoomInfo(
isDm = isDm, isDm = isDm,
name = name, name = name,
@ -248,4 +251,5 @@ internal fun aTimelineRoomInfo(
userHasPermissionToSendReaction = true, userHasPermissionToSendReaction = true,
isCallOngoing = false, isCallOngoing = false,
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState,
) )

View file

@ -30,9 +30,11 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
@ -55,25 +57,21 @@ 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent 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.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.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon 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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable @Composable
fun TimelineView( fun TimelineView(
state: TimelineState, state: TimelineState,
typingNotificationState: TypingNotificationState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onMessageClick: (TimelineItem.Event) -> Unit, onMessageClick: (TimelineItem.Event) -> Unit,
@ -131,11 +129,6 @@ fun TimelineView(
reverseLayout = useReverseLayout, reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
if (state.isLive) {
item {
TypingNotificationView(state = typingNotificationState)
}
}
items( items(
items = state.timelineItems, items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() }, contentType = { timelineItem -> timelineItem.contentType() },
@ -145,8 +138,7 @@ fun TimelineView(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = state.renderReadReceipts, renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
state.timelineItems.first().identifier() == timelineItem.identifier(),
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
@ -216,6 +208,7 @@ private fun BoxScope.TimelineScrollHelper(
lazyListState.firstVisibleItemIndex < 3 && isLive lazyListState.firstVisibleItemIndex < 3 && isLive
} }
} }
var jumpToLiveHandled by remember { mutableStateOf(true) }
fun scrollToBottom() { fun scrollToBottom() {
coroutineScope.launch { coroutineScope.launch {
@ -231,18 +224,22 @@ private fun BoxScope.TimelineScrollHelper(
if (isLive) { if (isLive) {
scrollToBottom() scrollToBottom()
} else { } else {
jumpToLiveHandled = false
onJumpToLive() onJumpToLive()
} }
} }
LaunchedEffect(jumpToLiveHandled, isLive) {
if (!jumpToLiveHandled && isLive) {
lazyListState.scrollToItem(0)
jumpToLiveHandled = true
}
}
val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender) val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender)
LaunchedEffect(focusRequestState) { LaunchedEffect(focusRequestState) {
if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed) { if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed && !focusRequestState.rendered) {
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) { lazyListState.animateScrollToItemCenter(focusRequestState.index)
lazyListState.animateScrollToItem(focusRequestState.index)
} else {
lazyListState.scrollToItem(focusRequestState.index)
}
latestOnFocusEventRender() latestOnFocusEventRender()
} }
} }
@ -323,7 +320,6 @@ internal fun TimelineViewPreview(
), ),
focusedEventIndex = 0, focusedEventIndex = 0,
), ),
typingNotificationState = aTypingNotificationState(),
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},

View file

@ -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.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -36,7 +35,6 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
timelineItems = items.toImmutableList(), timelineItems = items.toImmutableList(),
messageShield = messageShield, messageShield = messageShield,
), ),
typingNotificationState = aTypingNotificationState(),
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onMessageClick = {}, onMessageClick = {},

View file

@ -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.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel 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.TimelineItemRoomBeginningModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationView
@Composable @Composable
fun TimelineItemVirtualRow( fun TimelineItemVirtualRow(
@ -46,9 +48,15 @@ fun TimelineItemVirtualRow(
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction)) latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
} }
} }
// Empty model trick to avoid timeline jumping during forward pagination.
is TimelineItemLastForwardIndicatorModel -> { is TimelineItemLastForwardIndicatorModel -> {
Spacer(modifier = Modifier) Spacer(modifier = Modifier)
} }
is TimelineItemTypingNotificationModel -> {
TypingNotificationView(
state = timelineRoomInfo.typingNotificationState,
)
}
} }
} }
} }

View file

@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.timeline.factories
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory 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 dispatchers: CoroutineDispatchers,
private val virtualItemFactory: TimelineItemVirtualFactory, private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper, private val timelineItemGrouper: TimelineItemGrouper,
private val timelineItemIndexer: TimelineItemIndexer,
) { ) {
@AssistedFactory @AssistedFactory
interface Creator { interface Creator {
@ -96,7 +94,6 @@ class TimelineItemsFactory @AssistedInject constructor(
} }
} }
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
timelineItemIndexer.process(result)
this._timelineItems.emit(result) this._timelineItems.emit(result)
} }

View file

@ -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.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel 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.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.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
@ -39,6 +40,7 @@ class TimelineItemVirtualFactory @Inject constructor(
timestamp = inner.timestamp timestamp = inner.timestamp
) )
is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel
VirtualTimelineItem.TypingNotification -> TimelineItemTypingNotificationModel
} }
} }
} }

View file

@ -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"
}

View file

@ -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 = {},
)
}

View file

@ -40,7 +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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent 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.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter 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.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
@ -1048,6 +1048,7 @@ class MessagesPresenterTest {
timelineItemIndexer = TimelineItemIndexer(), timelineItemIndexer = TimelineItemIndexer(),
timelineController = TimelineController(matrixRoom), timelineController = TimelineController(matrixRoom),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
) )
val timelinePresenterFactory = object : TimelinePresenter.Factory { val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter { override fun create(navigator: MessagesNavigator): TimelinePresenter {
@ -1055,11 +1056,6 @@ class MessagesPresenterTest {
} }
} }
val featureFlagService = FakeFeatureFlagService() val featureFlagService = FakeFeatureFlagService()
val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore,
)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
@ -1069,7 +1065,6 @@ class MessagesPresenterTest {
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = voiceMessageComposerPresenter, voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenterFactory = timelinePresenterFactory, timelinePresenterFactory = timelinePresenterFactory,
typingNotificationPresenter = typingNotificationPresenter,
actionListPresenterFactory = FakeActionListPresenter.Factory, actionListPresenterFactory = FakeActionListPresenter.Factory,
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.fixtures 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.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory 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 io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
internal fun TestScope.aTimelineItemsFactoryCreator( internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Creator {
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelineItemsFactory.Creator {
return object : TimelineItemsFactory.Creator { return object : TimelineItemsFactory.Creator {
override fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory { override fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory {
return aTimelineItemsFactory(config, timelineItemIndexer) return aTimelineItemsFactory(config)
} }
} }
} }
internal fun TestScope.aTimelineItemsFactory( internal fun TestScope.aTimelineItemsFactory(
config: TimelineItemsFactoryConfig, config: TimelineItemsFactoryConfig,
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelineItemsFactory { ): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter() val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
@ -96,7 +92,6 @@ internal fun TestScope.aTimelineItemsFactory(
), ),
), ),
timelineItemGrouper = TimelineItemGrouper(), timelineItemGrouper = TimelineItemGrouper(),
timelineItemIndexer = timelineItemIndexer,
config = config config = config
) )
} }

View file

@ -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.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class TimelineItemIndexerTest { class TimelineItemIndexerTest {
@Test @Test
fun `test TimelineItemIndexer`() { fun `test TimelineItemIndexer`() = runTest {
val eventIds = mutableListOf<EventId>() val eventIds = mutableListOf<EventId>()
val data = listOf( val data = listOf(
aTimelineItemEvent().also { eventIds.add(it.eventId!!) }, aTimelineItemEvent().also { eventIds.add(it.eventId!!) },

View file

@ -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.components.aCriticalShield
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline
@ -503,7 +504,7 @@ import kotlin.time.Duration.Companion.seconds
assertThat(state.timelineItems).isNotEmpty() assertThat(state.timelineItems).isNotEmpty()
} }
initialState.eventSink.invoke(TimelineEvents.JumpToLive) initialState.eventSink.invoke(TimelineEvents.JumpToLive)
skipItems(1) skipItems(2)
awaitItem().also { state -> awaitItem().also { state ->
// Event stays focused // Event stays focused
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
@ -682,6 +683,7 @@ import kotlin.time.Duration.Companion.seconds
timelineItemIndexer = timelineItemIndexer, timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room), timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
) )
} }
} }

View file

@ -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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.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.UniqueId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
@ -139,7 +137,6 @@ class TimelineViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
state: TimelineState, state: TimelineState,
typingNotificationState: TypingNotificationState = aTypingNotificationState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
@ -155,7 +152,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
setSafeContent { setSafeContent {
TimelineView( TimelineView(
state = state, state = state,
typingNotificationState = typingNotificationState,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onMessageClick = onMessageClick, onMessageClick = onMessageClick,

View file

@ -7,6 +7,8 @@
package io.element.android.libraries.designsystem.utils 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.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -35,3 +37,38 @@ fun LazyListState.isScrollingUp(): Boolean {
} }
}.value }.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)
}
}

View file

@ -24,4 +24,6 @@ sealed interface VirtualTimelineItem {
val direction: Timeline.PaginationDirection, val direction: Timeline.PaginationDirection,
val timestamp: Long, val timestamp: Long,
) : VirtualTimelineItem ) : VirtualTimelineItem
data object TypingNotification : VirtualTimelineItem
} }

View file

@ -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.LastForwardIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor 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.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.timeline.reply.InReplyToMapper
import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -121,6 +122,7 @@ class RustTimeline(
private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode)
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
private val backPaginationStatus = MutableStateFlow( private val backPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS) Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
@ -235,6 +237,9 @@ class RustTimeline(
hasMoreToLoadForward = hasMoreToLoadForward hasMoreToLoadForward = hasMoreToLoadForward
) )
} }
.let { items ->
typingNotificationPostProcessor.process(items = items)
}
// Keep lastForwardIndicatorsPostProcessor last // Keep lastForwardIndicatorsPostProcessor last
.let { items -> .let { items ->
lastForwardIndicatorsPostProcessor.process( lastForwardIndicatorsPostProcessor.process(

View file

@ -0,0 +1,34 @@
/*
* 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<MatrixTimelineItem>): List<MatrixTimelineItem> {
return if (mode == Timeline.Mode.LIVE) {
buildList {
addAll(items)
add(
MatrixTimelineItem.Virtual(
uniqueId = UniqueId("TypingNotification"),
virtual = VirtualTimelineItem.TypingNotification
)
)
}
} else {
items
}
}
}