mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Feature: add jump to unread button with badge count
This commit is contained in:
+22
-16
@@ -500,7 +500,10 @@ private fun MessagesViewContent(
|
|||||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||||
)
|
)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
|
// Combined height of every banner overlaid above the timeline (pinned messages,
|
||||||
|
// knock requests). Used to push both the floating date badge and the jump-to-unread
|
||||||
|
// FAB below any banner that's currently showing.
|
||||||
|
var topBannersHeightDp by remember { mutableStateOf(0.dp) }
|
||||||
|
|
||||||
TimelineView(
|
TimelineView(
|
||||||
state = state.timelineState,
|
state = state.timelineState,
|
||||||
@@ -516,28 +519,31 @@ private fun MessagesViewContent(
|
|||||||
onReadReceiptClick = onReadReceiptClick,
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
floatingDateTopOffset = topBannersHeightDp,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||||
AnimatedVisibility(
|
Column(
|
||||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
|
||||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
|
||||||
enter = expandVertically(),
|
|
||||||
exit = shrinkVertically(),
|
|
||||||
) {
|
) {
|
||||||
fun focusOnPinnedEvent(eventId: EventId) {
|
AnimatedVisibility(
|
||||||
state.timelineState.eventSink(
|
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||||
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
|
fun focusOnPinnedEvent(eventId: EventId) {
|
||||||
|
state.timelineState.eventSink(
|
||||||
|
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PinnedMessagesBannerView(
|
||||||
|
state = state.pinnedMessagesBannerState,
|
||||||
|
onClick = ::focusOnPinnedEvent,
|
||||||
|
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PinnedMessagesBannerView(
|
knockRequestsBannerView()
|
||||||
state = state.pinnedMessagesBannerState,
|
|
||||||
onClick = ::focusOnPinnedEvent,
|
|
||||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
knockRequestsBannerView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-2
@@ -10,16 +10,19 @@ package io.element.android.features.messages.impl.timeline
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableIntState
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
@@ -32,6 +35,8 @@ 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.timeline.model.event.isMessageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||||
import io.element.android.features.messages.impl.userEventPermissions
|
import io.element.android.features.messages.impl.userEventPermissions
|
||||||
@@ -133,6 +138,7 @@ class TimelinePresenter(
|
|||||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
|
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
|
||||||
|
|
||||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||||
|
val newMessagesCount = remember { mutableIntStateOf(0) }
|
||||||
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
||||||
|
|
||||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||||
@@ -152,6 +158,9 @@ class TimelinePresenter(
|
|||||||
val displayFloatingDateBadge by produceState(false) {
|
val displayFloatingDateBadge by produceState(false) {
|
||||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||||
}
|
}
|
||||||
|
val displayJumpToUnread by produceState(false) {
|
||||||
|
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvent(event: TimelineEvent) {
|
fun handleEvent(event: TimelineEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -168,6 +177,7 @@ class TimelinePresenter(
|
|||||||
if (isLive) {
|
if (isLive) {
|
||||||
if (event.firstIndex == 0) {
|
if (event.firstIndex == 0) {
|
||||||
newEventState.value = NewEventState.None
|
newEventState.value = NewEventState.None
|
||||||
|
newMessagesCount.intValue = 0
|
||||||
}
|
}
|
||||||
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||||
sessionCoroutineScope.sendReadReceiptIfNeeded(
|
sessionCoroutineScope.sendReadReceiptIfNeeded(
|
||||||
@@ -268,7 +278,39 @@ class TimelinePresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(timelineItems.size) {
|
LaunchedEffect(timelineItems.size) {
|
||||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState, newMessagesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read marker position + unread count, scanned off the main thread. The UI gates display via
|
||||||
|
// [displayJumpToUnread]; the values are always computed so the state stays up to date if the
|
||||||
|
// feature flag flips at runtime.
|
||||||
|
val readMarkerIndex = remember { mutableIntStateOf(-1) }
|
||||||
|
val unreadMessagesCount = remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(timelineItems) {
|
||||||
|
val items = timelineItems
|
||||||
|
withContext(dispatchers.computation) {
|
||||||
|
var markerIdx = -1
|
||||||
|
var unread = 0
|
||||||
|
for ((i, item) in items.withIndex()) {
|
||||||
|
if ((item as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel) {
|
||||||
|
markerIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (item is TimelineItem.Event &&
|
||||||
|
!item.isMine &&
|
||||||
|
item.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
|
item.content.isMessageContent()
|
||||||
|
) {
|
||||||
|
unread++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply both writes atomically so consumers never see a half-updated pair
|
||||||
|
// (e.g. a non-negative markerIdx with the previous unread count).
|
||||||
|
Snapshot.withMutableSnapshot {
|
||||||
|
readMarkerIndex.intValue = markerIdx
|
||||||
|
unreadMessagesCount.intValue = if (markerIdx < 0) 0 else unread
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(timelineItems.size, focusRequestState.value) {
|
LaunchedEffect(timelineItems.size, focusRequestState.value) {
|
||||||
@@ -320,6 +362,10 @@ class TimelinePresenter(
|
|||||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||||
|
displayJumpToUnread = displayJumpToUnread,
|
||||||
|
readMarkerIndex = readMarkerIndex.intValue,
|
||||||
|
unreadMessagesCount = unreadMessagesCount.intValue,
|
||||||
|
newMessagesCount = newMessagesCount.intValue,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -377,11 +423,17 @@ class TimelinePresenter(
|
|||||||
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
||||||
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
||||||
* The state never goes back to None from this method, but need to be reset from somewhere else.
|
* The state never goes back to None from this method, but need to be reset from somewhere else.
|
||||||
|
*
|
||||||
|
* It also maintains [newMessagesCount], counting how many incoming messages from other users have arrived in
|
||||||
|
* each batch since [prevMostRecentItemId] — this drives the badge on the scroll-to-bottom button. The count is
|
||||||
|
* reset to zero when the most recent event is from the local user (they're back to active engagement); the
|
||||||
|
* scroll-finish handler resets it when the user returns to the bottom of the timeline.
|
||||||
*/
|
*/
|
||||||
private suspend fun computeNewItemState(
|
private suspend fun computeNewItemState(
|
||||||
timelineItems: ImmutableList<TimelineItem>,
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
prevMostRecentItemId: MutableState<UniqueId?>,
|
prevMostRecentItemId: MutableState<UniqueId?>,
|
||||||
newEventState: MutableState<NewEventState>
|
newEventState: MutableState<NewEventState>,
|
||||||
|
newMessagesCount: MutableIntState,
|
||||||
) = withContext(dispatchers.computation) {
|
) = withContext(dispatchers.computation) {
|
||||||
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
|
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
|
||||||
if (newEventState.value == NewEventState.FromMe) {
|
if (newEventState.value == NewEventState.FromMe) {
|
||||||
@@ -406,6 +458,22 @@ class TimelinePresenter(
|
|||||||
} else {
|
} else {
|
||||||
NewEventState.FromOther
|
NewEventState.FromOther
|
||||||
}
|
}
|
||||||
|
if (fromMe) {
|
||||||
|
newMessagesCount.intValue = 0
|
||||||
|
} else {
|
||||||
|
var delta = 0
|
||||||
|
for (item in timelineItems) {
|
||||||
|
if (item.identifier() == prevMostRecentItemIdValue) break
|
||||||
|
if (item is TimelineItem.Event &&
|
||||||
|
item.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
|
!item.isMine &&
|
||||||
|
item.content.isMessageContent()
|
||||||
|
) {
|
||||||
|
delta++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newMessagesCount.intValue += delta
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevMostRecentItemId.value = newMostRecentItemId
|
prevMostRecentItemId.value = newMostRecentItemId
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -35,6 +35,10 @@ data class TimelineState(
|
|||||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||||
val displayThreadSummaries: Boolean,
|
val displayThreadSummaries: Boolean,
|
||||||
val displayFloatingDateBadge: Boolean,
|
val displayFloatingDateBadge: Boolean,
|
||||||
|
val displayJumpToUnread: Boolean,
|
||||||
|
val readMarkerIndex: Int,
|
||||||
|
val unreadMessagesCount: Int,
|
||||||
|
val newMessagesCount: Int,
|
||||||
val eventSink: (TimelineEvent) -> Unit,
|
val eventSink: (TimelineEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||||
|
|||||||
+16
@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.anAggregatedReac
|
|||||||
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.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.TimelineItemReadMarkerModel
|
||||||
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.TypingNotificationState
|
||||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||||
@@ -57,6 +58,10 @@ fun aTimelineState(
|
|||||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||||
displayThreadSummaries: Boolean = false,
|
displayThreadSummaries: Boolean = false,
|
||||||
displayFloatingDateBadge: Boolean = false,
|
displayFloatingDateBadge: Boolean = false,
|
||||||
|
displayJumpToUnread: Boolean = true,
|
||||||
|
readMarkerIndex: Int = -1,
|
||||||
|
unreadMessagesCount: Int = 0,
|
||||||
|
newMessagesCount: Int = 0,
|
||||||
eventSink: (TimelineEvent) -> Unit = {},
|
eventSink: (TimelineEvent) -> Unit = {},
|
||||||
): TimelineState {
|
): TimelineState {
|
||||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||||
@@ -77,10 +82,21 @@ fun aTimelineState(
|
|||||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||||
|
displayJumpToUnread = displayJumpToUnread,
|
||||||
|
readMarkerIndex = readMarkerIndex,
|
||||||
|
unreadMessagesCount = unreadMessagesCount,
|
||||||
|
newMessagesCount = newMessagesCount,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun aTimelineItemReadMarker(): TimelineItem.Virtual {
|
||||||
|
return TimelineItem.Virtual(
|
||||||
|
id = UniqueId(UUID.randomUUID().toString()),
|
||||||
|
model = TimelineItemReadMarkerModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
return persistentListOf(
|
return persistentListOf(
|
||||||
// 3 items (First Middle Last) with isMine = false
|
// 3 items (First Middle Last) with isMine = false
|
||||||
|
|||||||
+184
-19
@@ -14,10 +14,14 @@ import androidx.compose.animation.core.tween
|
|||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -39,15 +43,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
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.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
@@ -70,15 +77,18 @@ 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.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
|
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.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -106,6 +116,7 @@ fun TimelineView(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
lazyListState: LazyListState = rememberLazyListState(),
|
lazyListState: LazyListState = rememberLazyListState(),
|
||||||
forceJumpToBottomVisibility: Boolean = false,
|
forceJumpToBottomVisibility: Boolean = false,
|
||||||
|
forceJumpToReadMarkerVisibility: Boolean = false,
|
||||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||||
floatingDateTopOffset: Dp = 0.dp,
|
floatingDateTopOffset: Dp = 0.dp,
|
||||||
) {
|
) {
|
||||||
@@ -205,9 +216,15 @@ fun TimelineView(
|
|||||||
hasAnyEvent = state.hasAnyEvent,
|
hasAnyEvent = state.hasAnyEvent,
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||||
|
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
|
||||||
newEventState = state.newEventState,
|
newEventState = state.newEventState,
|
||||||
isLive = state.isLive,
|
isLive = state.isLive,
|
||||||
focusRequestState = state.focusRequestState,
|
focusRequestState = state.focusRequestState,
|
||||||
|
readMarkerIndex = state.readMarkerIndex,
|
||||||
|
unreadMessagesCount = state.unreadMessagesCount,
|
||||||
|
newMessagesCount = state.newMessagesCount,
|
||||||
|
displayJumpToUnread = state.displayJumpToUnread,
|
||||||
|
topInset = floatingDateTopOffset,
|
||||||
onScrollFinishAt = ::onScrollFinishAt,
|
onScrollFinishAt = ::onScrollFinishAt,
|
||||||
onJumpToLive = ::onJumpToLive,
|
onJumpToLive = ::onJumpToLive,
|
||||||
onFocusEventRender = ::onFocusEventRender,
|
onFocusEventRender = ::onFocusEventRender,
|
||||||
@@ -289,7 +306,13 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
newEventState: NewEventState,
|
newEventState: NewEventState,
|
||||||
isLive: Boolean,
|
isLive: Boolean,
|
||||||
forceJumpToBottomVisibility: Boolean,
|
forceJumpToBottomVisibility: Boolean,
|
||||||
|
forceJumpToReadMarkerVisibility: Boolean,
|
||||||
focusRequestState: FocusRequestState,
|
focusRequestState: FocusRequestState,
|
||||||
|
readMarkerIndex: Int,
|
||||||
|
unreadMessagesCount: Int,
|
||||||
|
newMessagesCount: Int,
|
||||||
|
displayJumpToUnread: Boolean,
|
||||||
|
topInset: Dp,
|
||||||
onScrollFinishAt: (Int) -> Unit,
|
onScrollFinishAt: (Int) -> Unit,
|
||||||
onJumpToLive: () -> Unit,
|
onJumpToLive: () -> Unit,
|
||||||
onFocusEventRender: () -> Unit,
|
onFocusEventRender: () -> Unit,
|
||||||
@@ -301,6 +324,19 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
lazyListState.firstVisibleItemIndex < 3 && isLive
|
lazyListState.firstVisibleItemIndex < 3 && isLive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isReadMarkerOffTop by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (!displayJumpToUnread || readMarkerIndex < 0) {
|
||||||
|
false
|
||||||
|
} else if (forceJumpToReadMarkerVisibility) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
|
||||||
|
readMarkerIndex > lastVisibleIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
|
||||||
var jumpToLiveHandled by remember { mutableStateOf(true) }
|
var jumpToLiveHandled by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,6 +363,13 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun jumpToReadMarker() {
|
||||||
|
if (readMarkerIndex < 0) return
|
||||||
|
coroutineScope.launch {
|
||||||
|
lazyListState.animateScrollToItemCenter(readMarkerIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(jumpToLiveHandled, isLive) {
|
LaunchedEffect(jumpToLiveHandled, isLive) {
|
||||||
if (!jumpToLiveHandled && isLive) {
|
if (!jumpToLiveHandled && isLive) {
|
||||||
lazyListState.scrollToItem(0)
|
lazyListState.scrollToItem(0)
|
||||||
@@ -358,19 +401,41 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
JumpToBottomButton(
|
TimelineFab(
|
||||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
icon = CompoundIcons.ChevronDown(),
|
||||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
||||||
|
isVisible = isJumpToBottomVisible,
|
||||||
|
// Hide the badge entirely when the feature is off, regardless of the count value.
|
||||||
|
count = if (displayJumpToUnread) newMessagesCount else 0,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
onClick = { jumpToBottom() },
|
onClick = ::jumpToBottom,
|
||||||
|
)
|
||||||
|
val jumpToUnreadDescription = if (unreadMessagesCount > 0) {
|
||||||
|
pluralStringResource(CommonPlurals.a11y_jump_to_unread_messages_count, unreadMessagesCount, unreadMessagesCount)
|
||||||
|
} else {
|
||||||
|
stringResource(id = CommonStrings.a11y_jump_to_unread_messages)
|
||||||
|
}
|
||||||
|
TimelineFab(
|
||||||
|
icon = CompoundIcons.ChevronUp(),
|
||||||
|
contentDescription = jumpToUnreadDescription,
|
||||||
|
isVisible = isReadMarkerOffTop,
|
||||||
|
count = unreadMessagesCount,
|
||||||
|
// Top padding includes [topInset] so the FAB sits below any pinned-events banner.
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(end = 24.dp, top = topInset + 12.dp),
|
||||||
|
onClick = ::jumpToReadMarker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun JumpToBottomButton(
|
private fun TimelineFab(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
|
count: Int,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -380,25 +445,67 @@ private fun JumpToBottomButton(
|
|||||||
enter = scaleIn(animationSpec = tween(100)),
|
enter = scaleIn(animationSpec = tween(100)),
|
||||||
exit = scaleOut(animationSpec = tween(100)),
|
exit = scaleOut(animationSpec = tween(100)),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
Box {
|
||||||
onClick = onClick,
|
FloatingActionButton(
|
||||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
onClick = onClick,
|
||||||
shape = CircleShape,
|
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||||
modifier = Modifier.size(36.dp),
|
shape = CircleShape,
|
||||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
modifier = Modifier.size(36.dp),
|
||||||
contentColor = ElementTheme.colors.iconSecondary,
|
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||||
) {
|
contentColor = ElementTheme.colors.iconSecondary,
|
||||||
Icon(
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TimelineCountBadge(
|
||||||
|
count = count,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(24.dp)
|
.align(Alignment.TopEnd)
|
||||||
.rotate(90f),
|
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
|
||||||
imageVector = CompoundIcons.ArrowRight(),
|
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small accent badge overlaid on a timeline FAB. Shows the count when it's between 1 and 9, otherwise a dot.
|
||||||
|
* Renders nothing when [count] is zero or negative.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TimelineCountBadge(
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (count <= 0) return
|
||||||
|
if (count <= 9) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp)
|
||||||
|
.background(color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape)
|
||||||
|
.border(width = 2.dp, color = ElementTheme.colors.iconOnSolidPrimary, shape = CircleShape)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = count.toString(),
|
||||||
|
color = ElementTheme.colors.textOnSolidPrimary,
|
||||||
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.background(color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape)
|
||||||
|
.border(width = 2.dp, color = ElementTheme.colors.iconOnSolidPrimary, shape = CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineViewPreview(
|
internal fun TimelineViewPreview(
|
||||||
@@ -433,3 +540,61 @@ internal fun TimelineViewPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimelineViewWithReadMarker(
|
||||||
|
unreadMessagesCount: Int,
|
||||||
|
newMessagesCount: Int,
|
||||||
|
) {
|
||||||
|
val readMarker = aTimelineItemReadMarker()
|
||||||
|
val timelineItems = persistentListOf<TimelineItem>(
|
||||||
|
aTimelineItemEvent(isMine = false),
|
||||||
|
aTimelineItemEvent(isMine = false),
|
||||||
|
aTimelineItemEvent(isMine = true),
|
||||||
|
readMarker,
|
||||||
|
aTimelineItemEvent(isMine = false),
|
||||||
|
aTimelineItemEvent(isMine = false),
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||||
|
) {
|
||||||
|
TimelineView(
|
||||||
|
state = aTimelineState(
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
readMarkerIndex = timelineItems.indexOf(readMarker),
|
||||||
|
unreadMessagesCount = unreadMessagesCount,
|
||||||
|
newMessagesCount = newMessagesCount,
|
||||||
|
),
|
||||||
|
timelineProtectionState = aTimelineProtectionState(),
|
||||||
|
onUserDataClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
|
onContentClick = {},
|
||||||
|
onMessageLongClick = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
|
forceJumpToBottomVisibility = true,
|
||||||
|
forceJumpToReadMarkerVisibility = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineViewWithReadMarkerNoBadgesPreview() = ElementPreview {
|
||||||
|
TimelineViewWithReadMarker(unreadMessagesCount = 0, newMessagesCount = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineViewWithReadMarkerPreview() = ElementPreview {
|
||||||
|
TimelineViewWithReadMarker(unreadMessagesCount = 3, newMessagesCount = 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineViewWithReadMarkerDotBadgesPreview() = ElementPreview {
|
||||||
|
TimelineViewWithReadMarker(unreadMessagesCount = 47, newMessagesCount = 99)
|
||||||
|
}
|
||||||
|
|||||||
+22
@@ -99,6 +99,28 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
|
|||||||
*/
|
*/
|
||||||
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
|
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the event content is a user-facing message that should be counted toward unread totals.
|
||||||
|
* Excludes state events, profile changes, membership changes, redactions, and unknown content.
|
||||||
|
*/
|
||||||
|
fun TimelineItemEventContent.isMessageContent(): Boolean = when (this) {
|
||||||
|
is TimelineItemTextBasedContent,
|
||||||
|
is TimelineItemAudioContent,
|
||||||
|
is TimelineItemEncryptedContent,
|
||||||
|
is TimelineItemFileContent,
|
||||||
|
is TimelineItemImageContent,
|
||||||
|
is TimelineItemStickerContent,
|
||||||
|
is TimelineItemLocationContent,
|
||||||
|
is TimelineItemPollContent,
|
||||||
|
is TimelineItemVoiceContent,
|
||||||
|
is TimelineItemVideoContent,
|
||||||
|
is TimelineItemLegacyCallInviteContent,
|
||||||
|
is TimelineItemRtcNotificationContent -> true
|
||||||
|
is TimelineItemStateContent,
|
||||||
|
is TimelineItemRedactedContent,
|
||||||
|
TimelineItemUnknownContent -> false
|
||||||
|
}
|
||||||
|
|
||||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is TimelineItemAudioContent -> duration
|
is TimelineItemAudioContent -> duration
|
||||||
|
|||||||
+302
@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
@@ -58,12 +59,14 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss
|
|||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
@@ -365,6 +368,305 @@ class TimelinePresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - unreadMessagesCount counts message-content items between newest and read marker, excluding state events`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
assertThat(initialState.readMarkerIndex).isEqualTo(-1)
|
||||||
|
assertThat(initialState.unreadMessagesCount).isEqualTo(0)
|
||||||
|
// SDK delivers items oldest-first; the factory reverses so output index 0 is the newest.
|
||||||
|
// After processing: [msg-newest, membership, msg-2, read-marker, msg-old]
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-2"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("membership"), anEventTimelineItem(content = aRoomMembershipContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.readMarkerIndex >= 0 }.last().also { state ->
|
||||||
|
// 2 message items above the marker; the membership state event is skipped.
|
||||||
|
assertThat(state.readMarkerIndex).isEqualTo(3)
|
||||||
|
assertThat(state.unreadMessagesCount).isEqualTo(2)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - unreadMessagesCount excludes own messages and PAGINATION-origin events`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
// After processing (factory reverses): [other-newest, own-msg, paginated, read-marker, msg-old]
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||||
|
MatrixTimelineItem.Event(
|
||||||
|
UniqueId("paginated"),
|
||||||
|
anEventTimelineItem(content = aMessageContent()).copy(origin = TimelineItemEventOrigin.PAGINATION),
|
||||||
|
),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("own-msg"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("other-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.readMarkerIndex >= 0 }.last().also { state ->
|
||||||
|
assertThat(state.readMarkerIndex).isEqualTo(3)
|
||||||
|
// Only `other-newest` counts: own-msg and paginated are filtered out.
|
||||||
|
assertThat(state.unreadMessagesCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - readMarkerIndex is -1 when no read marker present`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
assertThat(initialState.readMarkerIndex).isEqualTo(-1)
|
||||||
|
assertThat(initialState.unreadMessagesCount).isEqualTo(0)
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||||
|
assertThat(state.readMarkerIndex).isEqualTo(-1)
|
||||||
|
assertThat(state.unreadMessagesCount).isEqualTo(0)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount increments by N when N events from others arrive in one batch`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
assertThat(initialState.newMessagesCount).isEqualTo(0)
|
||||||
|
// Seed prevMostRecentItemId so subsequent emissions count as new events.
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
// Three new events from another user arrive in a single batch.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("3"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 3 }.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(3)
|
||||||
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount resets to 0 on OnScrollFinished firstIndex 0`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
markAsReadResult = { Result.success(Unit) },
|
||||||
|
)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
val countedState = consumeItemsUntilPredicate { it.newMessagesCount == 1 }.last()
|
||||||
|
assertThat(countedState.newMessagesCount).isEqualTo(1)
|
||||||
|
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 0 }.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(0)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount resets to 0 when latest event is from me`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
// First, an event from another user increments the count.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 1 }
|
||||||
|
// Then the local user sends a message: count should reset.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate {
|
||||||
|
it.newEventState == NewEventState.FromMe && it.newMessagesCount == 0
|
||||||
|
}.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(0)
|
||||||
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount does not reset on OnScrollFinished firstIndex other than 0`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 1 }
|
||||||
|
// Scrolling stops above the bottom: the count must NOT reset.
|
||||||
|
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
|
||||||
|
advanceUntilIdle()
|
||||||
|
// No state should emit with newMessagesCount == 0.
|
||||||
|
val drained = consumeItemsUntilTimeout()
|
||||||
|
assertThat(drained.any { it.newMessagesCount == 0 }).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount does not increment for events with PAGINATION origin`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
// A back-paginated event arrives. It should not bump the badge.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(
|
||||||
|
MatrixTimelineItem.Event(
|
||||||
|
UniqueId("paginated"),
|
||||||
|
anEventTimelineItem(content = aMessageContent()).copy(origin = TimelineItemEventOrigin.PAGINATION),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(0)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount does not increment for state events`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
// A membership change arrives. It should not bump the badge.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("membership"), anEventTimelineItem(content = aRoomMembershipContent())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(0)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - readMarkerIndex is 0 when the read marker is the only item`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.readMarkerIndex >= 0 }.last().also { state ->
|
||||||
|
assertThat(state.readMarkerIndex).isEqualTo(0)
|
||||||
|
assertThat(state.unreadMessagesCount).isEqualTo(0)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newMessagesCount accumulates across multiple batches as prevMostRecentItemId advances`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(timeline)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
// Seed prevMostRecentItemId so subsequent emissions count as new events.
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
|
// Batch 1: 1 new event → count = 1.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(UniqueId("b1-1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 1 }
|
||||||
|
// Batch 2: 2 more new events → count = 3.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("b2-1"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("b2-2"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 3 }
|
||||||
|
// Batch 3: 1 more new event → count = 4.
|
||||||
|
timelineItems.getAndUpdate { items ->
|
||||||
|
items + listOf(MatrixTimelineItem.Event(UniqueId("b3-1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
|
}
|
||||||
|
consumeItemsUntilPredicate { it.newMessagesCount == 4 }.last().also { state ->
|
||||||
|
assertThat(state.newMessagesCount).isEqualTo(4)
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - reaction ordering`() = runTest {
|
fun `present - reaction ordering`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
|||||||
+7
@@ -136,6 +136,13 @@ enum class FeatureFlags(
|
|||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
JumpToUnread(
|
||||||
|
key = "feature.jump_to_unread",
|
||||||
|
title = "Jump to unread messages",
|
||||||
|
description = "Show a button to jump to the read marker, plus a count badge on the scroll-to-bottom button when new messages arrive while scrolled away.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
SlashCommand(
|
SlashCommand(
|
||||||
key = "feature.slash_command",
|
key = "feature.slash_command",
|
||||||
title = "Parse slash commands in the message composer",
|
title = "Parse slash commands in the message composer",
|
||||||
|
|||||||
@@ -7,4 +7,9 @@
|
|||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<string name="common_black">"Black"</string>
|
<string name="common_black">"Black"</string>
|
||||||
|
<string name="a11y_jump_to_unread_messages">"Jump to unread messages"</string>
|
||||||
|
<plurals name="a11y_jump_to_unread_messages_count">
|
||||||
|
<item quantity="one">"Jump to %1$d unread message"</item>
|
||||||
|
<item quantity="other">"Jump to %1$d unread messages"</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ class KonsistPreviewTest {
|
|||||||
"TimelineItemVoiceViewUnifiedPreview",
|
"TimelineItemVoiceViewUnifiedPreview",
|
||||||
"TimelineVideoWithCaptionRowPreview",
|
"TimelineVideoWithCaptionRowPreview",
|
||||||
"TimelineViewMessageShieldPreview",
|
"TimelineViewMessageShieldPreview",
|
||||||
|
"TimelineViewWithReadMarkerDotBadgesPreview",
|
||||||
|
"TimelineViewWithReadMarkerNoBadgesPreview",
|
||||||
"UserAvatarColorsPreview",
|
"UserAvatarColorsPreview",
|
||||||
"UserProfileHeaderSectionWithVerificationViolationPreview",
|
"UserProfileHeaderSectionWithVerificationViolationPreview",
|
||||||
"VoiceItemViewPlayPreview",
|
"VoiceItemViewPlayPreview",
|
||||||
|
|||||||
Reference in New Issue
Block a user