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,
|
||||
)
|
||||
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(
|
||||
state = state.timelineState,
|
||||
@@ -516,28 +519,31 @@ private fun MessagesViewContent(
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
||||
floatingDateTopOffset = topBannersHeightDp,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
Column(
|
||||
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
|
||||
) {
|
||||
fun focusOnPinnedEvent(eventId: EventId) {
|
||||
state.timelineState.eventSink(
|
||||
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
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(
|
||||
state = state.pinnedMessagesBannerState,
|
||||
onClick = ::focusOnPinnedEvent,
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-2
@@ -10,16 +10,19 @@ package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
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.model.NewEventState
|
||||
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.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
@@ -133,6 +138,7 @@ class TimelinePresenter(
|
||||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
|
||||
|
||||
val newEventState = remember { mutableStateOf(NewEventState.None) }
|
||||
val newMessagesCount = remember { mutableIntStateOf(0) }
|
||||
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
||||
|
||||
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
|
||||
@@ -152,6 +158,9 @@ class TimelinePresenter(
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
}
|
||||
val displayJumpToUnread by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
@@ -168,6 +177,7 @@ class TimelinePresenter(
|
||||
if (isLive) {
|
||||
if (event.firstIndex == 0) {
|
||||
newEventState.value = NewEventState.None
|
||||
newMessagesCount.intValue = 0
|
||||
}
|
||||
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||
sessionCoroutineScope.sendReadReceiptIfNeeded(
|
||||
@@ -268,7 +278,39 @@ class TimelinePresenter(
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -320,6 +362,10 @@ class TimelinePresenter(
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
readMarkerIndex = readMarkerIndex.intValue,
|
||||
unreadMessagesCount = unreadMessagesCount.intValue,
|
||||
newMessagesCount = newMessagesCount.intValue,
|
||||
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.
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
timelineItems: ImmutableList<TimelineItem>,
|
||||
prevMostRecentItemId: MutableState<UniqueId?>,
|
||||
newEventState: MutableState<NewEventState>
|
||||
newEventState: MutableState<NewEventState>,
|
||||
newMessagesCount: MutableIntState,
|
||||
) = withContext(dispatchers.computation) {
|
||||
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
|
||||
if (newEventState.value == NewEventState.FromMe) {
|
||||
@@ -406,6 +458,22 @@ class TimelinePresenter(
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
+4
@@ -35,6 +35,10 @@ data class TimelineState(
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val readMarkerIndex: Int,
|
||||
val unreadMessagesCount: Int,
|
||||
val newMessagesCount: Int,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
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.aTimelineItemStateEventContent
|
||||
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.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
@@ -57,6 +58,10 @@ fun aTimelineState(
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayFloatingDateBadge: Boolean = false,
|
||||
displayJumpToUnread: Boolean = true,
|
||||
readMarkerIndex: Int = -1,
|
||||
unreadMessagesCount: Int = 0,
|
||||
newMessagesCount: Int = 0,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||
@@ -77,10 +82,21 @@ fun aTimelineState(
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
readMarkerIndex = readMarkerIndex,
|
||||
unreadMessagesCount = unreadMessagesCount,
|
||||
newMessagesCount = newMessagesCount,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTimelineItemReadMarker(): TimelineItem.Virtual {
|
||||
return TimelineItem.Virtual(
|
||||
id = UniqueId(UUID.randomUUID().toString()),
|
||||
model = TimelineItemReadMarkerModel,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||
return persistentListOf(
|
||||
// 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.scaleIn
|
||||
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.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -39,15 +43,18 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
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.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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.theme.components.FloatingActionButton
|
||||
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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
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.utils.time.isTalkbackActive
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -106,6 +116,7 @@ fun TimelineView(
|
||||
modifier: Modifier = Modifier,
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
forceJumpToReadMarkerVisibility: Boolean = false,
|
||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
floatingDateTopOffset: Dp = 0.dp,
|
||||
) {
|
||||
@@ -205,9 +216,15 @@ fun TimelineView(
|
||||
hasAnyEvent = state.hasAnyEvent,
|
||||
lazyListState = lazyListState,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
|
||||
newEventState = state.newEventState,
|
||||
isLive = state.isLive,
|
||||
focusRequestState = state.focusRequestState,
|
||||
readMarkerIndex = state.readMarkerIndex,
|
||||
unreadMessagesCount = state.unreadMessagesCount,
|
||||
newMessagesCount = state.newMessagesCount,
|
||||
displayJumpToUnread = state.displayJumpToUnread,
|
||||
topInset = floatingDateTopOffset,
|
||||
onScrollFinishAt = ::onScrollFinishAt,
|
||||
onJumpToLive = ::onJumpToLive,
|
||||
onFocusEventRender = ::onFocusEventRender,
|
||||
@@ -289,7 +306,13 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
newEventState: NewEventState,
|
||||
isLive: Boolean,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
forceJumpToReadMarkerVisibility: Boolean,
|
||||
focusRequestState: FocusRequestState,
|
||||
readMarkerIndex: Int,
|
||||
unreadMessagesCount: Int,
|
||||
newMessagesCount: Int,
|
||||
displayJumpToUnread: Boolean,
|
||||
topInset: Dp,
|
||||
onScrollFinishAt: (Int) -> Unit,
|
||||
onJumpToLive: () -> Unit,
|
||||
onFocusEventRender: () -> Unit,
|
||||
@@ -301,6 +324,19 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
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) }
|
||||
|
||||
/**
|
||||
@@ -327,6 +363,13 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
}
|
||||
|
||||
fun jumpToReadMarker() {
|
||||
if (readMarkerIndex < 0) return
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItemCenter(readMarkerIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(jumpToLiveHandled, isLive) {
|
||||
if (!jumpToLiveHandled && isLive) {
|
||||
lazyListState.scrollToItem(0)
|
||||
@@ -358,19 +401,41 @@ private fun BoxScope.TimelineScrollHelper(
|
||||
}
|
||||
}
|
||||
|
||||
JumpToBottomButton(
|
||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||
TimelineFab(
|
||||
icon = CompoundIcons.ChevronDown(),
|
||||
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
|
||||
.align(Alignment.BottomEnd)
|
||||
.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
|
||||
private fun JumpToBottomButton(
|
||||
private fun TimelineFab(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
isVisible: Boolean,
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -380,25 +445,67 @@ private fun JumpToBottomButton(
|
||||
enter = scaleIn(animationSpec = tween(100)),
|
||||
exit = scaleOut(animationSpec = tween(100)),
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(36.dp),
|
||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
) {
|
||||
Icon(
|
||||
Box {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(36.dp),
|
||||
containerColor = ElementTheme.colors.bgSubtleSecondary,
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
TimelineCountBadge(
|
||||
count = count,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(90f),
|
||||
imageVector = CompoundIcons.ArrowRight(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
||||
.align(Alignment.TopEnd)
|
||||
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Composable
|
||||
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
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
return when (this) {
|
||||
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.ReactionSender
|
||||
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.test.AN_EVENT_ID
|
||||
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.aMessageContent
|
||||
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.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
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.assert
|
||||
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
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
|
||||
+7
@@ -136,6 +136,13 @@ enum class FeatureFlags(
|
||||
defaultValue = { 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(
|
||||
key = "feature.slash_command",
|
||||
title = "Parse slash commands in the message composer",
|
||||
|
||||
@@ -7,4 +7,9 @@
|
||||
|
||||
<resources>
|
||||
<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>
|
||||
|
||||
@@ -160,6 +160,8 @@ class KonsistPreviewTest {
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineVideoWithCaptionRowPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"TimelineViewWithReadMarkerDotBadgesPreview",
|
||||
"TimelineViewWithReadMarkerNoBadgesPreview",
|
||||
"UserAvatarColorsPreview",
|
||||
"UserProfileHeaderSectionWithVerificationViolationPreview",
|
||||
"VoiceItemViewPlayPreview",
|
||||
|
||||
Reference in New Issue
Block a user