mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Move jumpToUnreadButton to bottom, remove badge count
This commit is contained in:
+8
-45
@@ -30,10 +30,8 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
|
|||||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
|
||||||
import io.element.android.features.messages.impl.timeline.model.JumpToUnreadState
|
|
||||||
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.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
|
||||||
@@ -281,26 +279,16 @@ class TimelinePresenter(
|
|||||||
// read marker advances in place — the SDK swaps the marker virtual item to a new position
|
// read marker advances in place — the SDK swaps the marker virtual item to a new position
|
||||||
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
|
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
|
||||||
// bottom of the room.
|
// bottom of the room.
|
||||||
val jumpToUnreadState = remember { mutableStateOf<JumpToUnreadState>(JumpToUnreadState.Disabled) }
|
val readMarkerIndex = remember { mutableStateOf<Int?>(null) }
|
||||||
LaunchedEffect(timelineItems, displayJumpToUnread) {
|
LaunchedEffect(timelineItems, displayJumpToUnread) {
|
||||||
if (!displayJumpToUnread) {
|
if (!displayJumpToUnread) {
|
||||||
jumpToUnreadState.value = JumpToUnreadState.Disabled
|
readMarkerIndex.value = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
val items = timelineItems
|
val items = timelineItems
|
||||||
jumpToUnreadState.value = withContext(dispatchers.computation) {
|
readMarkerIndex.value = withContext(dispatchers.computation) {
|
||||||
var markerIdx = -1
|
items.indexOfFirst { (it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel }
|
||||||
var unread = 0
|
.takeIf { it >= 0 }
|
||||||
for ((i, item) in items.withIndex()) {
|
|
||||||
if ((item as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel) {
|
|
||||||
markerIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (item is TimelineItem.Event && item.isCountableNewMessage()) {
|
|
||||||
unread++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (markerIdx < 0) JumpToUnreadState.NoMarker else JumpToUnreadState.Loaded(markerIdx, unread)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +341,8 @@ class TimelinePresenter(
|
|||||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||||
jumpToUnreadState = jumpToUnreadState.value,
|
displayJumpToUnread = displayJumpToUnread,
|
||||||
|
readMarkerIndex = readMarkerIndex.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -411,10 +400,6 @@ 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.
|
||||||
*
|
|
||||||
* The [NewEventState.FromOther] variant carries the running count of incoming messages from other users
|
|
||||||
* since [prevMostRecentItemId] last advanced to a local-user event or the timeline returned to the bottom
|
|
||||||
* — this drives the badge on the scroll-to-bottom button.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun computeNewItemState(
|
private suspend fun computeNewItemState(
|
||||||
timelineItems: ImmutableList<TimelineItem>,
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
@@ -438,19 +423,7 @@ class TimelinePresenter(
|
|||||||
|
|
||||||
if (hasNewEvent) {
|
if (hasNewEvent) {
|
||||||
// Scroll to bottom if the new event is from me, even if sent from another device
|
// Scroll to bottom if the new event is from me, even if sent from another device
|
||||||
if (newMostRecentItem.isMine) {
|
newEventState.value = if (newMostRecentItem.isMine) NewEventState.FromMe else NewEventState.FromOther
|
||||||
newEventState.value = NewEventState.FromMe
|
|
||||||
} else {
|
|
||||||
var delta = 0
|
|
||||||
for (item in timelineItems) {
|
|
||||||
if (item.identifier() == prevMostRecentItemIdValue) break
|
|
||||||
if (item is TimelineItem.Event && item.isCountableNewMessage()) {
|
|
||||||
delta++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val previousCount = (newEventState.value as? NewEventState.FromOther)?.messageCount ?: 0
|
|
||||||
newEventState.value = NewEventState.FromOther(previousCount + delta)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
prevMostRecentItemId.value = newMostRecentItemId
|
prevMostRecentItemId.value = newMostRecentItemId
|
||||||
}
|
}
|
||||||
@@ -496,16 +469,6 @@ private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this event should be counted toward the unread / new-message badges: a user-facing
|
|
||||||
* message from someone other than the local user, that wasn't pulled in via back-pagination.
|
|
||||||
*/
|
|
||||||
private fun TimelineItem.Event.isCountableNewMessage(): Boolean {
|
|
||||||
return !isMine &&
|
|
||||||
origin != TimelineItemEventOrigin.PAGINATION &&
|
|
||||||
content.isMessageContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workaround for not having the server names available, get possible server names from the user ids of the room members
|
// Workaround for not having the server names available, get possible server names from the user ids of the room members
|
||||||
private fun calculateServerNamesForRoom(room: JoinedRoom): List<String> {
|
private fun calculateServerNamesForRoom(room: JoinedRoom): List<String> {
|
||||||
// If we have no room members, return right ahead
|
// If we have no room members, return right ahead
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,6 @@ package io.element.android.features.messages.impl.timeline
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
|
||||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||||
import io.element.android.features.messages.impl.timeline.model.JumpToUnreadState
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||||
@@ -36,7 +35,8 @@ data class TimelineState(
|
|||||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||||
val displayThreadSummaries: Boolean,
|
val displayThreadSummaries: Boolean,
|
||||||
val displayFloatingDateBadge: Boolean,
|
val displayFloatingDateBadge: Boolean,
|
||||||
val jumpToUnreadState: JumpToUnreadState,
|
val displayJumpToUnread: Boolean,
|
||||||
|
val readMarkerIndex: 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
|
||||||
|
|||||||
+4
-3
@@ -12,7 +12,6 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
|
|||||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
||||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.JumpToUnreadState
|
|
||||||
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.ReadReceiptData
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
@@ -58,7 +57,8 @@ fun aTimelineState(
|
|||||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||||
displayThreadSummaries: Boolean = false,
|
displayThreadSummaries: Boolean = false,
|
||||||
displayFloatingDateBadge: Boolean = false,
|
displayFloatingDateBadge: Boolean = false,
|
||||||
jumpToUnreadState: JumpToUnreadState = JumpToUnreadState.NoMarker,
|
displayJumpToUnread: Boolean = false,
|
||||||
|
readMarkerIndex: Int? = null,
|
||||||
newEventState: NewEventState = NewEventState.None,
|
newEventState: NewEventState = NewEventState.None,
|
||||||
eventSink: (TimelineEvent) -> Unit = {},
|
eventSink: (TimelineEvent) -> Unit = {},
|
||||||
): TimelineState {
|
): TimelineState {
|
||||||
@@ -80,7 +80,8 @@ fun aTimelineState(
|
|||||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||||
displayThreadSummaries = displayThreadSummaries,
|
displayThreadSummaries = displayThreadSummaries,
|
||||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||||
jumpToUnreadState = jumpToUnreadState,
|
displayJumpToUnread = displayJumpToUnread,
|
||||||
|
readMarkerIndex = readMarkerIndex,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-82
@@ -19,7 +19,6 @@ 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.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -49,9 +48,7 @@ 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.IntOffset
|
||||||
@@ -65,7 +62,6 @@ import io.element.android.features.messages.impl.timeline.components.toText
|
|||||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||||
import io.element.android.features.messages.impl.timeline.model.JumpToUnreadState
|
|
||||||
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.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
@@ -78,14 +74,12 @@ 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
|
||||||
@@ -221,8 +215,8 @@ fun TimelineView(
|
|||||||
newEventState = state.newEventState,
|
newEventState = state.newEventState,
|
||||||
isLive = state.isLive,
|
isLive = state.isLive,
|
||||||
focusRequestState = state.focusRequestState,
|
focusRequestState = state.focusRequestState,
|
||||||
jumpToUnreadState = state.jumpToUnreadState,
|
displayJumpToUnread = state.displayJumpToUnread,
|
||||||
topInset = floatingDateTopOffset,
|
readMarkerIndex = state.readMarkerIndex,
|
||||||
onScrollFinishAt = ::onScrollFinishAt,
|
onScrollFinishAt = ::onScrollFinishAt,
|
||||||
onJumpToLive = ::onJumpToLive,
|
onJumpToLive = ::onJumpToLive,
|
||||||
onFocusEventRender = ::onFocusEventRender,
|
onFocusEventRender = ::onFocusEventRender,
|
||||||
@@ -306,8 +300,8 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
forceJumpToBottomVisibility: Boolean,
|
forceJumpToBottomVisibility: Boolean,
|
||||||
forceJumpToReadMarkerVisibility: Boolean,
|
forceJumpToReadMarkerVisibility: Boolean,
|
||||||
focusRequestState: FocusRequestState,
|
focusRequestState: FocusRequestState,
|
||||||
jumpToUnreadState: JumpToUnreadState,
|
displayJumpToUnread: Boolean,
|
||||||
topInset: Dp,
|
readMarkerIndex: Int?,
|
||||||
onScrollFinishAt: (Int) -> Unit,
|
onScrollFinishAt: (Int) -> Unit,
|
||||||
onJumpToLive: () -> Unit,
|
onJumpToLive: () -> Unit,
|
||||||
onFocusEventRender: () -> Unit,
|
onFocusEventRender: () -> Unit,
|
||||||
@@ -321,14 +315,10 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
}
|
}
|
||||||
val isJumpToUnreadVisible by remember {
|
val isJumpToUnreadVisible by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
when {
|
if (forceJumpToReadMarkerVisibility) return@derivedStateOf true
|
||||||
forceJumpToReadMarkerVisibility -> true
|
val markerIndex = readMarkerIndex ?: return@derivedStateOf false
|
||||||
jumpToUnreadState !is JumpToUnreadState.Loaded -> false
|
|
||||||
else -> {
|
|
||||||
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
|
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
|
||||||
jumpToUnreadState.markerIndex > lastVisibleIndex
|
markerIndex > lastVisibleIndex
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
|
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
|
||||||
@@ -359,9 +349,9 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun jumpToReadMarker() {
|
fun jumpToReadMarker() {
|
||||||
val loaded = jumpToUnreadState as? JumpToUnreadState.Loaded ?: return
|
val markerIndex = readMarkerIndex ?: return
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItemCenter(loaded.markerIndex)
|
lazyListState.animateScrollToItemCenter(markerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,28 +393,21 @@ private fun BoxScope.TimelineScrollHelper(
|
|||||||
icon = CompoundIcons.ChevronDown(),
|
icon = CompoundIcons.ChevronDown(),
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
|
||||||
isVisible = isJumpToBottomVisible,
|
isVisible = isJumpToBottomVisible,
|
||||||
// Hide the badge entirely when the feature is off, regardless of the count value.
|
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
|
||||||
count = if (jumpToUnreadState is JumpToUnreadState.Disabled) 0 else newEventState.messageCount,
|
|
||||||
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 unreadCount = (jumpToUnreadState as? JumpToUnreadState.Loaded)?.unreadCount ?: 0
|
|
||||||
val jumpToUnreadDescription = if (unreadCount > 0) {
|
|
||||||
pluralStringResource(CommonPlurals.a11y_jump_to_unread_messages_count, unreadCount, unreadCount)
|
|
||||||
} else {
|
|
||||||
stringResource(id = CommonStrings.a11y_jump_to_unread_messages)
|
|
||||||
}
|
|
||||||
JumpToPositionButton(
|
JumpToPositionButton(
|
||||||
icon = CompoundIcons.ChevronUp(),
|
icon = CompoundIcons.ChevronUp(),
|
||||||
contentDescription = jumpToUnreadDescription,
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
|
||||||
isVisible = isJumpToUnreadVisible,
|
isVisible = isJumpToUnreadVisible,
|
||||||
count = unreadCount,
|
hasUnread = true,
|
||||||
// Top padding includes [topInset] so the FAB sits below any pinned-events banner.
|
// Stacked directly above the scroll-to-bottom FAB: 12dp base + 36dp FAB + 8dp gap.
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, top = topInset + 12.dp),
|
.padding(end = 24.dp, bottom = 56.dp),
|
||||||
onClick = ::jumpToReadMarker,
|
onClick = ::jumpToReadMarker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -434,7 +417,7 @@ private fun JumpToPositionButton(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
count: Int,
|
hasUnread: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -459,8 +442,8 @@ private fun JumpToPositionButton(
|
|||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TimelineCountBadge(
|
TimelineUnreadIndicator(
|
||||||
count = count,
|
isVisible = hasUnread,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
|
.offset { IntOffset(x = 4.dp.roundToPx(), y = -4.dp.roundToPx()) },
|
||||||
@@ -469,40 +452,19 @@ private fun JumpToPositionButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
private fun TimelineCountBadge(
|
private fun TimelineUnreadIndicator(
|
||||||
count: Int,
|
isVisible: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when {
|
if (!isVisible) return
|
||||||
count <= 0 -> return
|
Box(
|
||||||
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
|
modifier = modifier
|
||||||
.size(12.dp)
|
.size(12.dp)
|
||||||
.background(color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape)
|
.background(color = ElementTheme.colors.iconSuccessPrimary, shape = CircleShape)
|
||||||
.border(width = 2.dp, color = ElementTheme.colors.iconOnSolidPrimary, shape = CircleShape),
|
.border(width = 2.dp, color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
@@ -541,8 +503,8 @@ internal fun TimelineViewPreview(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineViewWithReadMarker(
|
private fun TimelineViewWithReadMarker(
|
||||||
unreadMessagesCount: Int,
|
hasUnreadAbove: Boolean,
|
||||||
newMessagesCount: Int,
|
hasUnreadBelow: Boolean,
|
||||||
) {
|
) {
|
||||||
val timelineItems = persistentListOf<TimelineItem>(
|
val timelineItems = persistentListOf<TimelineItem>(
|
||||||
aTimelineItemEvent(isMine = false),
|
aTimelineItemEvent(isMine = false),
|
||||||
@@ -558,11 +520,12 @@ private fun TimelineViewWithReadMarker(
|
|||||||
TimelineView(
|
TimelineView(
|
||||||
state = aTimelineState(
|
state = aTimelineState(
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
|
displayJumpToUnread = true,
|
||||||
// Index points past the loaded items, mirroring the real-world state the FAB
|
// Index points past the loaded items, mirroring the real-world state the FAB
|
||||||
// represents: the user has scrolled past the read marker, so it's no longer in
|
// represents: the user has scrolled past the read marker, so it's no longer in
|
||||||
// view. The actual scroll target doesn't matter for a static preview.
|
// view. The actual scroll target doesn't matter for a static preview.
|
||||||
jumpToUnreadState = JumpToUnreadState.Loaded(markerIndex = timelineItems.size, unreadCount = unreadMessagesCount),
|
readMarkerIndex = if (hasUnreadAbove) timelineItems.size else null,
|
||||||
newEventState = if (newMessagesCount > 0) NewEventState.FromOther(newMessagesCount) else NewEventState.None,
|
newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None,
|
||||||
),
|
),
|
||||||
timelineProtectionState = aTimelineProtectionState(),
|
timelineProtectionState = aTimelineProtectionState(),
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
@@ -582,24 +545,12 @@ private fun TimelineViewWithReadMarker(
|
|||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineViewWithReadMarkerNoBadgesPreview() = ElementPreview {
|
internal fun TimelineViewWithReadMarkerNoIndicatorsPreview() = ElementPreview {
|
||||||
TimelineViewWithReadMarker(unreadMessagesCount = 0, newMessagesCount = 0)
|
TimelineViewWithReadMarker(hasUnreadAbove = false, hasUnreadBelow = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineViewWithReadMarkerNumericBadgePreview() = ElementPreview {
|
internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview {
|
||||||
TimelineViewWithReadMarker(unreadMessagesCount = 3, newMessagesCount = 0)
|
TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true)
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun TimelineViewWithReadMarkerDotBadgesPreview() = ElementPreview {
|
|
||||||
TimelineViewWithReadMarker(unreadMessagesCount = 47, newMessagesCount = 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun TimelineViewWithReadMarkerBothCountsPreview() = ElementPreview {
|
|
||||||
TimelineViewWithReadMarker(unreadMessagesCount = 5, newMessagesCount = 3)
|
|
||||||
}
|
}
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the jump-to-unread FAB and the count badge on the scroll-to-bottom FAB.
|
|
||||||
*
|
|
||||||
* The two affordances share state because they're both gated on the same feature flag and both
|
|
||||||
* use counts derived from the same timeline scan.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
sealed interface JumpToUnreadState {
|
|
||||||
/** Feature flag is off — neither the FAB nor the new-message badge is shown. */
|
|
||||||
data object Disabled : JumpToUnreadState
|
|
||||||
|
|
||||||
/** Feature flag is on, but no read marker is present in the current timeline window. */
|
|
||||||
data object NoMarker : JumpToUnreadState
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature flag is on and the read marker is loaded at [markerIndex]. The FAB shows when the
|
|
||||||
* marker is above the viewport; the badge displays [unreadCount] when greater than zero.
|
|
||||||
*/
|
|
||||||
data class Loaded(val markerIndex: Int, val unreadCount: Int) : JumpToUnreadState
|
|
||||||
}
|
|
||||||
+1
-6
@@ -13,15 +13,10 @@ import androidx.compose.runtime.Immutable
|
|||||||
/**
|
/**
|
||||||
* Model if there is a new event in the timeline and if it is from me or from other.
|
* Model if there is a new event in the timeline and if it is from me or from other.
|
||||||
* This can be used to scroll to the bottom of the list when a new event is added.
|
* This can be used to scroll to the bottom of the list when a new event is added.
|
||||||
*
|
|
||||||
* [FromOther] also carries the running count of incoming messages from other users since the
|
|
||||||
* timeline was last at the bottom — used to drive the badge on the scroll-to-bottom button.
|
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface NewEventState {
|
sealed interface NewEventState {
|
||||||
val messageCount: Int get() = 0
|
|
||||||
|
|
||||||
data object None : NewEventState
|
data object None : NewEventState
|
||||||
data object FromMe : NewEventState
|
data object FromMe : NewEventState
|
||||||
data class FromOther(override val messageCount: Int) : NewEventState
|
data object FromOther : NewEventState
|
||||||
}
|
}
|
||||||
|
|||||||
-22
@@ -99,28 +99,6 @@ 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
|
||||||
|
|||||||
+52
-130
@@ -16,7 +16,6 @@ import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
|||||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
|
||||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||||
import io.element.android.features.messages.impl.timeline.model.JumpToUnreadState
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||||
@@ -364,17 +363,14 @@ class TimelinePresenterTest {
|
|||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
|
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
// Both events received during the FromMe window are counted now that the timeline
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||||
// has progressed past it: prevMostRecentItemId points at the local user's "1",
|
|
||||||
// so "2" and "3" are both new countable messages.
|
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther(2))
|
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - jumpToUnreadState reports loaded marker and unread count, excluding state events`() = runTest {
|
fun `present - readMarkerIndex points at the read marker virtual item`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
@@ -394,47 +390,16 @@ class TimelinePresenterTest {
|
|||||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate { it.jumpToUnreadState is JumpToUnreadState.Loaded }.last().also { state ->
|
consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state ->
|
||||||
// 2 message items above the marker; the membership state event is skipped.
|
assertThat(state.readMarkerIndex).isEqualTo(3)
|
||||||
assertThat(state.jumpToUnreadState).isEqualTo(JumpToUnreadState.Loaded(markerIndex = 3, unreadCount = 2))
|
assertThat(state.displayJumpToUnread).isTrue()
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - jumpToUnreadState count excludes own messages and PAGINATION-origin events`() = runTest {
|
fun `present - readMarkerIndex is null when no read marker present`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
|
||||||
val presenter = createTimelinePresenter(
|
|
||||||
timeline = timeline,
|
|
||||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
|
||||||
)
|
|
||||||
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.jumpToUnreadState is JumpToUnreadState.Loaded }.last().also { state ->
|
|
||||||
// Only `other-newest` counts: own-msg and paginated are filtered out.
|
|
||||||
assertThat(state.jumpToUnreadState).isEqualTo(JumpToUnreadState.Loaded(markerIndex = 3, unreadCount = 1))
|
|
||||||
}
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - jumpToUnreadState is NoMarker when no read marker present`() = runTest {
|
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
@@ -450,16 +415,41 @@ class TimelinePresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate {
|
consumeItemsUntilPredicate {
|
||||||
it.timelineItems.size == 2 && it.jumpToUnreadState == JumpToUnreadState.NoMarker
|
it.timelineItems.size == 2 && it.readMarkerIndex == null
|
||||||
}.last().also { state ->
|
}.last().also { state ->
|
||||||
assertThat(state.jumpToUnreadState).isEqualTo(JumpToUnreadState.NoMarker)
|
assertThat(state.readMarkerIndex).isNull()
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - FromOther count increments by N when N events from others arrive in one batch`() = runTest {
|
fun `present - readMarkerIndex stays null when JumpToUnread feature flag is disabled`() = runTest {
|
||||||
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
|
val presenter = createTimelinePresenter(
|
||||||
|
timeline = timeline,
|
||||||
|
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitFirstItem()
|
||||||
|
timelineItems.emit(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||||
|
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state ->
|
||||||
|
assertThat(state.displayJumpToUnread).isFalse()
|
||||||
|
assertThat(state.readMarkerIndex).isNull()
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - newEventState becomes FromOther when an event from another user arrives`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
@@ -471,16 +461,11 @@ class TimelinePresenterTest {
|
|||||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
// Three new events from another user arrive in a single batch.
|
|
||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
|
||||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
|
|
||||||
MatrixTimelineItem.Event(UniqueId("3"), anEventTimelineItem(content = aMessageContent())),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther(3) }.last().also { state ->
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }.last().also { state ->
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther(3))
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
@@ -503,8 +488,7 @@ class TimelinePresenterTest {
|
|||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
}
|
}
|
||||||
val countedState = consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther(1) }.last()
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||||
assertThat(countedState.newEventState).isEqualTo(NewEventState.FromOther(1))
|
|
||||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
|
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.None }.last().also { state ->
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.None }.last().also { state ->
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||||
@@ -514,7 +498,7 @@ class TimelinePresenterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - newEventState transitions to FromMe when latest event is from me, dropping the count`() = runTest {
|
fun `present - newEventState transitions to FromMe when latest event is from me`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
@@ -524,12 +508,12 @@ class TimelinePresenterTest {
|
|||||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
// First, an event from another user increments the count.
|
// First, an event from another user moves us to FromOther.
|
||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther(1) }
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||||
// Then the local user sends a message: state moves to FromMe (which carries no count).
|
// Then the local user sends a message: state moves to FromMe.
|
||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(
|
items + listOf(
|
||||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
||||||
@@ -537,14 +521,13 @@ class TimelinePresenterTest {
|
|||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromMe }.last().also { state ->
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromMe }.last().also { state ->
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||||
assertThat(state.newEventState.messageCount).isEqualTo(0)
|
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - FromOther count does not reset on OnScrollFinished firstIndex other than 0`() = runTest {
|
fun `present - newEventState does not reset on OnScrollFinished firstIndex other than 0`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
@@ -557,19 +540,18 @@ class TimelinePresenterTest {
|
|||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther(1) }
|
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||||
// Scrolling stops above the bottom: the count must NOT reset.
|
// Scrolling stops above the bottom: state must NOT reset.
|
||||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
|
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
// No state should emit with the count back at 0.
|
|
||||||
val drained = consumeItemsUntilTimeout()
|
val drained = consumeItemsUntilTimeout()
|
||||||
assertThat(drained.any { it.newEventState.messageCount == 0 }).isFalse()
|
assertThat(drained.any { it.newEventState == NewEventState.None }).isFalse()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - FromOther count does not increment for events with PAGINATION origin`() = runTest {
|
fun `present - newEventState stays None for events with PAGINATION origin`() = runTest {
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(timeline)
|
val presenter = createTimelinePresenter(timeline)
|
||||||
@@ -579,7 +561,7 @@ class TimelinePresenterTest {
|
|||||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||||
// A back-paginated event arrives. It should not bump the badge.
|
// A back-paginated event arrives. It should not flip newEventState.
|
||||||
timelineItems.getAndUpdate { items ->
|
timelineItems.getAndUpdate { items ->
|
||||||
items + listOf(
|
items + listOf(
|
||||||
MatrixTimelineItem.Event(
|
MatrixTimelineItem.Event(
|
||||||
@@ -589,38 +571,14 @@ class TimelinePresenterTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||||
assertThat(state.newEventState.messageCount).isEqualTo(0)
|
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - FromOther count does not increment for state events`() = runTest {
|
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.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.newEventState.messageCount).isEqualTo(0)
|
|
||||||
}
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - jumpToUnreadState markerIndex is 0 when the read marker is the only item`() = runTest {
|
|
||||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||||
val presenter = createTimelinePresenter(
|
val presenter = createTimelinePresenter(
|
||||||
@@ -632,44 +590,8 @@ class TimelinePresenterTest {
|
|||||||
timelineItems.emit(
|
timelineItems.emit(
|
||||||
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
|
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
|
||||||
)
|
)
|
||||||
consumeItemsUntilPredicate { it.jumpToUnreadState is JumpToUnreadState.Loaded }.last().also { state ->
|
consumeItemsUntilPredicate { it.readMarkerIndex != null }.last().also { state ->
|
||||||
assertThat(state.jumpToUnreadState).isEqualTo(JumpToUnreadState.Loaded(markerIndex = 0, unreadCount = 0))
|
assertThat(state.readMarkerIndex).isEqualTo(0)
|
||||||
}
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - FromOther count 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.newEventState == NewEventState.FromOther(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.newEventState == NewEventState.FromOther(3) }
|
|
||||||
// Batch 3: 1 more new event → count = 4.
|
|
||||||
timelineItems.getAndUpdate { items ->
|
|
||||||
items + listOf(MatrixTimelineItem.Event(UniqueId("b3-1"), anEventTimelineItem(content = aMessageContent())))
|
|
||||||
}
|
|
||||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther(4) }.last().also { state ->
|
|
||||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther(4))
|
|
||||||
}
|
}
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user