diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 37db268648..b42d5b4321 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -889,6 +889,10 @@ Message Not Sent Your message could not be delivered. Would you like to try again? Today + + %1$s Unread Message + %1$s Unread Messages + Send Money To Your Friends Send money to friends as easily as a text. Connect your phone number to get started diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index d14d74190e..459b903778 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -37,4 +37,5 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) testImplementation(libs.robolectric) + testImplementation(libs.androidx.paging.testing) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index c281ba160e..00470df4c4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -13,7 +13,6 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap -import androidx.paging.insertSeparators import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.contacts.ContactCoordinator @@ -28,6 +27,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.messenger.internal.link.CashCardTap +import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver @@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -264,6 +265,14 @@ internal class ChatViewModel @Inject constructor( * it. Set alongside [jumpTarget] and cleared with it. */ val jumpBudget: Int? = null, + /** + * Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored + * READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a + * boundary that followed it would slide the divider away from what was new at open. + */ + val unreadBoundary: UnreadBoundary = UnreadBoundary.Resolving, + /** How many stored messages sit past the boundary, which bounds the walk that opens at it. */ + val unreadWalkBudget: Int = 0, /** * True while the delete confirmation is up. * @@ -576,6 +585,9 @@ internal class ChatViewModel @Inject constructor( /** The same request, once the walk's bound is known. */ data class JumpResolved(val messageId: Long, val budget: Int) : Event + + /** The chat's unread boundary, read once when its id became known. */ + data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event data object JumpConsumed : Event } @@ -664,8 +676,12 @@ internal class ChatViewModel @Inject constructor( */ private val claimReplyTargets = ClaimReplyTargets() + /** + * The transcript's rows before separators. Cached so a change to the unread boundary or the + * separator config re-runs only [messages]' separator pass, not the per-message lookups here. + */ @OptIn(ExperimentalCoroutinesApi::class) - val messages: Flow> = + private val mappedMessages: Flow> = combine( messageStream, pendingMutations, @@ -771,13 +787,26 @@ internal class ChatViewModel @Inject constructor( .splitAroundLinkCard() .asReversed() } - }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> - if (before == null || after == null) return@insertSeparators null - if (stateFlow.value.separatorConfig.shouldSeparate(before.timestamp, after.timestamp)) { - ChatListItem.DateSeparator(before.timestamp) - } else null } - } + }.cachedIn(viewModelScope) + + /** + * The transcript as the list draws it: [mappedMessages] with date separators and the unread + * divider. The boundary and config are inputs rather than reads of the current state, so a + * page never takes its separators from one config and its neighbour's from another. + * + * Nothing is emitted while the boundary is still resolving. The list decides where to open from + * the first page it lays out, and a page drawn before the boundary landed has no divider to + * open at. Both wait on the same chat id, and the resolution is two local queries. + */ + val messages: Flow> = + combine( + mappedMessages, + stateFlow.map { it.unreadBoundary } + .filterNot { it is UnreadBoundary.Resolving } + .distinctUntilChanged(), + stateFlow.map { it.separatorConfig }.distinctUntilChanged(), + ) { paging, boundary, config -> paging.withSeparators(boundary, config) } /** * The citation shown for [this] message. @@ -1741,6 +1770,19 @@ internal class ChatViewModel @Inject constructor( dispatchEvent(Event.JumpResolved(event.messageId, distance)) } .launchIn(viewModelScope) + + // Read once per chat id. The list holds read reporting until this lands, so the pointer + // it reads is the one the viewer arrived with. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .onEach { chatId -> + val boundary = chatCoordinator.resolveUnreadBoundary(chatId) + val budget = (boundary as? UnreadBoundary.At) + ?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) } + ?: 0 + dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget)) + } + .launchIn(viewModelScope) } /** Leaves edit mode, restoring the draft the edit interrupted. */ @@ -2162,6 +2204,11 @@ internal class ChatViewModel @Inject constructor( private val DRAFT_WRITE_DEBOUNCE = 300.milliseconds val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + stateReducer(event, UNREAD_DIVIDER_LIFETIME) + } + + /** [updateStateForEvent] under an explicit [lifetime], so tests can cover both policies. */ + internal fun stateReducer(event: Event, lifetime: UnreadDividerLifetime): (State) -> State = when (event) { is Event.OnChatOpened -> { state -> when (val id = event.identifier) { @@ -2274,7 +2321,11 @@ internal class ChatViewModel @Inject constructor( is Event.ResolveFailed -> { state -> state.copy(resolveState = ResolveState.Failed) } - is Event.SendMessage -> { state -> state } + is Event.SendMessage -> { state -> + val clears = lifetime == UnreadDividerLifetime.UntilSend && + state.unreadBoundary is UnreadBoundary.At + if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state + } is Event.RetryMessage -> { state -> state } Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) } @@ -2385,7 +2436,9 @@ internal class ChatViewModel @Inject constructor( state.copy(jumpTarget = event.messageId, jumpBudget = event.budget) } Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } + is Event.UnreadBoundaryResolved -> { state -> + state.copy(unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget) + } } - } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt new file mode 100644 index 0000000000..723daac54a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal + +import androidx.paging.PagingData +import androidx.paging.insertSeparators +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SeparatorConfig + +/** How long the unread divider stays once a chat is open. iOS carries the same switch. */ +internal enum class UnreadDividerLifetime { + /** The whole visit. */ + UntilClose, + + /** Until the viewer sends a message. */ + UntilSend, +} + +internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose + +/** + * Whether the unread divider goes in the gap between [newer] and [older]. [older] is `null` at the + * end of pagination, when every stored message is unread. + * + * Compares ranges rather than matching an id, so a read-through message that was deleted or never + * stored does not matter. The rows of one message share a `messageId`, so the divider never lands + * inside a message. + */ +internal fun unreadDividerBetween( + newer: ChatListItem.ContentBubble, + older: ChatListItem.ContentBubble?, + boundary: UnreadBoundary, +): Boolean { + if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false + val olderId = older?.messageId ?: Long.MIN_VALUE + return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId +} + +/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */ +internal fun separatorBetween( + newer: ChatListItem.ContentBubble?, + older: ChatListItem.ContentBubble?, + boundary: UnreadBoundary, + config: SeparatorConfig, +): ChatListItem? { + newer ?: return null + if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) { + // The oldest stored message has no separator of its own; the list draws its date as a + // trailing header. The divider sits there instead, so it carries that date. + val dayChanges = older == null || config.shouldSeparate(newer.timestamp, older.timestamp) + return ChatListItem.UnreadDivider(boundary.count, date = newer.timestamp.takeIf { dayChanges }) + } + older ?: return null + return if (config.shouldSeparate(newer.timestamp, older.timestamp)) { + ChatListItem.DateSeparator(newer.timestamp) + } else null +} + +internal fun PagingData.withSeparators( + boundary: UnreadBoundary, + config: SeparatorConfig, +): PagingData = + insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? -> + separatorBetween(newer, older, boundary, config) + } + +/** The count the divider shows: past 99 it reads "99+". */ +internal fun unreadCountLabel(count: Int): String = if (count > 99) "99+" else count.toString() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index d46d99f8dd..a8ecbbd21f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -41,6 +41,7 @@ import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem @@ -88,7 +89,12 @@ internal fun MessageList( LocalChatActionHandler provides onAction, LocalLinkCardResolution provides linkCardResolution, ) { - HandleMessageReads(listState, messages) + // Whether the list has settled where this visit opens it: at the unread divider, or at the + // bottom when there is none. Saveable for the same reason refreshSettled is — the screen is + // rebuilt under the amount-entry step, and this visit's opening must not replay on the way + // back. + var hasPositioned by rememberSaveable { mutableStateOf(false) } + HandleMessageReads(listState, messages, enabled = hasPositioned) // Haptic feedback when a new incoming message arrives // Track the newest message key — only fire when it changes to an incoming message @@ -167,32 +173,7 @@ internal fun MessageList( val budget = (state.jumpBudget?.plus(JUMP_PAGE_SIZE) ?: MAX_JUMP_ITEMS) .coerceAtMost(MAX_JUMP_ITEMS) - var index = indexOf(messages, target) - while (index == null && messages.loadedCount <= budget) { - // Touching the last loaded index is what emits the ViewportHint that drives one - // more append. One page at a time: with placeholders on, itemCount is the whole - // chat, so hinting at the end would append at the far side of history instead. - val hint = messages.appendHintIndex - if (hint < 0) break - - val before = messages.loadedCount - messages[hint] - - // Wait for that append to land rather than for a frame: one frame is not a - // guarantee, and a walk that races the pager gives up and scrolls nowhere. - // loadedCount grows either way — placeholders on, placeholdersAfter shrinks; off, - // itemCount grows. - val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) { - snapshotFlow { messages.loadedCount to messages.loadState.append } - .first { (count, append) -> count > before || append.endOfPaginationReached } - .first > before - } - if (progressed != true) break - - index = indexOf(messages, target) - } - - index?.let { + messages.walkUntil(budget) { indexOf(messages, target) }?.let { listState.centerItem(it) attentionId = target attentionRequest++ @@ -203,6 +184,32 @@ internal fun MessageList( onJumpConsumed() } + // Opens the chat at the first unread message. The first layout is at the bottom, like any + // other chat; if the divider is already on screen there, that is where it stays. Otherwise + // the list walks back to it the way a jump does and puts it just under the top bar, with + // the unread messages running down from it. Reads are held until this settles, so the + // bottom of the transcript is not marked read by the frame that passes through it. + LaunchedEffect(state.unreadBoundary) { + when (val boundary = state.unreadBoundary) { + UnreadBoundary.Resolving -> return@LaunchedEffect + UnreadBoundary.None -> hasPositioned = true + is UnreadBoundary.At -> { + if (hasPositioned) return@LaunchedEffect + snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() } + .first { it } + if (!listState.layoutInfo.showsUnreadDivider(messages)) { + val budget = (state.unreadWalkBudget + JUMP_PAGE_SIZE) + .coerceAtMost(MAX_JUMP_ITEMS) + // Not reached in budget: the chat stays at the bottom, as it would have + // opened without a divider. + messages.walkUntil(budget) { unreadDividerIndex(messages) } + ?.let { listState.alignBelowTopBar(it) } + } + hasPositioned = true + } + } + } + // Holds the focused message at the position it was long-pressed at, against the keyboard // shortening the list from below — whether the keyboard came up for the edit or because the // composer was tapped with the selection bar still showing. See FocusPin. @@ -355,6 +362,7 @@ internal fun MessageList( val oldestTimestamp = when (oldest) { is ChatListItem.ContentBubble -> oldest.timestamp is ChatListItem.DateSeparator -> null // already a separator + is ChatListItem.UnreadDivider -> null // carries the oldest message's date null -> null } if (oldestTimestamp != null) { @@ -469,6 +477,69 @@ private val LazyPagingItems.loadedCount: Int private val LazyPagingItems.appendHintIndex: Int get() = (itemSnapshotList.placeholdersBefore + loadedCount).coerceAtMost(itemCount - 1) +/** + * Appends one page at a time until [find] answers or [budget] items are loaded, and returns what + * [find] last answered. + */ +private suspend fun LazyPagingItems.walkUntil(budget: Int, find: () -> Int?): Int? { + var index = find() + while (index == null && loadedCount <= budget) { + // Touching the last loaded index is what emits the ViewportHint that drives one more + // append. One page at a time: with placeholders on, itemCount is the whole chat, so hinting + // at the end would append at the far side of history instead. + val hint = appendHintIndex + if (hint < 0) break + + val before = loadedCount + this[hint] + + // Wait for that append to land rather than for a frame: one frame is not a guarantee, and a + // walk that races the pager gives up and scrolls nowhere. loadedCount grows either way — + // placeholders on, placeholdersAfter shrinks; off, itemCount grows. + val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) { + snapshotFlow { loadedCount to loadState.append } + .first { (count, append) -> count > before || append.endOfPaginationReached } + .first > before + } + if (progressed != true) break + + index = find() + } + return index +} + +/** The presented index of the unread divider, or `null` while it is still unloaded. */ +private fun unreadDividerIndex(messages: LazyPagingItems): Int? = + (0 until messages.itemCount).firstOrNull { messages.peek(it) is ChatListItem.UnreadDivider } + +/** + * Whether the unread divider is laid out whole below the top bar. A divider under the bar's fade + * counts as off screen: the viewer can't read it there. + */ +private fun LazyListLayoutInfo.showsUnreadDivider(messages: LazyPagingItems): Boolean { + val band = centeringBand() + return visibleItemsInfo.any { info -> + info.index < messages.itemCount && + messages.peek(info.index) is ChatListItem.UnreadDivider && + info.offset >= band.first && info.offset + info.size <= band.last + } +} + +/** + * Puts [index] directly under the top bar, with no animation: the chat is opening there, not + * moving there. + * + * The item's upper edge goes at the top of the centering band rather than its lower edge — the top + * of the band is the bar's lower edge, so anything above it would sit under the bar's fade. + */ +private suspend fun LazyListState.alignBelowTopBar(index: Int) { + val band = layoutInfo.centeringBand() + val height = measuredHeight(index) ?: 0 + // In this reverseLayout list the item's leading edge is its bottom, placed at -offset from the + // band's start (its bottom edge), so the room it leaves over goes below it. + scrollToItem(index, scrollOffset = -(band.last - band.first - height).coerceAtLeast(0)) +} + /** The presented index of [messageId], or `null` while it is still unloaded. */ private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? = (0 until messages.itemCount).firstOrNull { i -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt index 7d6323cadc..deb5ddb389 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt @@ -19,16 +19,21 @@ import kotlinx.coroutines.flow.filterNotNull /** * Reports the newest incoming message the viewer has actually had on screen, so the read pointer * follows the scroll rather than the arrival. + * + * Nothing is reported until [enabled]: a chat opening at its unread divider lays out at the bottom + * first, and reporting that frame would mark read everything the divider is there to point at. */ @Composable internal fun HandleMessageReads( listState: LazyListState, messages: LazyPagingItems, + enabled: Boolean, ) { val actionHandler = LocalChatActionHandler.current var lastAdvanced by remember { mutableLongStateOf(0L) } - LaunchedEffect(listState, messages) { + LaunchedEffect(listState, messages, enabled) { + if (!enabled) return@LaunchedEffect snapshotFlow { val layout = listState.layoutInfo val count = messages.itemCount diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index c4a57e140d..9a9fdb797c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -213,6 +213,10 @@ internal fun MessageRow( DateSeparatorRow(item.timestamp) } + is ChatListItem.UnreadDivider -> Box(insertionModifier) { + UnreadDividerRow(count = item.count, date = item.date) + } + is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) // Track whether this item was ever seen as SENDING so we @@ -456,24 +460,21 @@ internal enum class RowGap { Tight, Normal, Wide } * group: two members' messages are both incoming, so a whole conversation between them ran at the * tight same-sender gap. * - * Pure so it can be tested without a `PagingData`, like [startsSenderRun]: the messenger module has - * no `paging-testing` dependency, and the peek belongs to the caller. + * Pure so it can be tested without a `PagingData`, like [startsSenderRun]: the peek belongs to the + * caller. */ internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: SeparatorConfig): RowGap { below ?: return RowGap.Tight - // Separator adjacent → normal gap - if (item is ChatListItem.DateSeparator || below is ChatListItem.DateSeparator) { + // Separator or unread divider adjacent → normal gap + if (item !is ChatListItem.ContentBubble || below !is ChatListItem.ContentBubble) { return RowGap.Normal } - val current = item as? ChatListItem.ContentBubble ?: return RowGap.Tight - val newer = below as? ChatListItem.ContentBubble ?: return RowGap.Tight - return when { - !current.isSameAuthorAs(newer) -> RowGap.Wide + !item.isSameAuthorAs(below) -> RowGap.Wide // Same sender, outside grouping window → normal - !config.isGrouped(current.timestamp, newer.timestamp) -> RowGap.Normal + !config.isGrouped(item.timestamp, below.timestamp) -> RowGap.Normal // Same sender, close together → tight else -> RowGap.Tight } @@ -490,8 +491,7 @@ internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: Separ * first frame: a group's profile map arrives after the first page, and until it does every member's * bubble has a null `sender`. * - * Pure so it can be tested without a `PagingData`: the messenger module has no `paging-testing` - * dependency, and the peek belongs to the caller anyway. + * Pure so it can be tested without a `PagingData`: the peek belongs to the caller. */ internal fun startsSenderRun(current: ChatListItem.ContentBubble, older: ChatListItem?): Boolean { val author = current.authorId ?: return false diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt new file mode 100644 index 0000000000..f35c34c17d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.messenger.internal.unreadCountLabel +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * "N Unread Messages", a caption between two hairlines above the first message that arrived since + * the viewer last read the chat. When a day also changes at that gap, [date] is drawn above it, so the + * reader sees the day, then the divider, then the messages. + */ +@Composable +internal fun UnreadDividerRow( + count: Int, + date: Instant?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (date != null) DateSeparatorRow(date) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x2, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + HorizontalDivider(modifier = Modifier.weight(1f), color = CodeTheme.colors.divider) + Text( + text = pluralStringResource(R.plurals.title_chatUnreadDivider, count, unreadCountLabel(count)), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + HorizontalDivider(modifier = Modifier.weight(1f), color = CodeTheme.colors.divider) + } + } +} + +// region Previews + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UnreadDivider() { + Column { + UnreadDividerRow(count = 1, date = null) + UnreadDividerRow(count = 12, date = Clock.System.now()) + UnreadDividerRow(count = 250, date = null) + } +} + +// endregion diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt index 317993da17..b532297a90 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt @@ -105,6 +105,40 @@ class GroupTranscriptScreenshotTest { capture("group_transcript.png") } + /** + * The unread divider between read and unread messages, first on its own and then sharing its + * gap with a day change, where the date sits above it. + */ + @Test + fun rendersUnreadDivider() { + val alone = listOf( + bubble(5, "Watch the tape", chloe, secondsIn = 40), + bubble(4, "What are you talking about. Did you see him last night?", chloe, secondsIn = 30), + ChatListItem.UnreadDivider(count = 2, date = null), + bubble(3, "Fine, Saturday then", sender = null, isFromSelf = true, secondsIn = 20), + bubble(2, "I disagree. He isn't the best at all", noah, secondsIn = 10), + ChatListItem.DateSeparator(start), + ) + val withDate = listOf( + bubble(5, "Watch the tape", chloe, secondsIn = 90_000), + ChatListItem.UnreadDivider(count = 1, date = start + 90_000.seconds), + bubble(3, "Fine, Saturday then", sender = null, isFromSelf = true, secondsIn = 20), + ChatListItem.DateSeparator(start), + ) + + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + Transcript(flowOf(PagingData.from(alone)).collectAsLazyPagingItems()) + Transcript(flowOf(PagingData.from(withDate)).collectAsLazyPagingItems()) + } + } + } + repeat(20) { composeRule.mainClock.advanceTimeByFrame() } + + capture("unread_divider.png") + } + /** * The same transcript seen from outside the group, once per access: an eligible viewer reads it * sharp over the Join gate, while a blocked one and one whose balance has not arrived yet see it diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt index 897e2fb9cb..7e8fba524e 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt @@ -40,6 +40,13 @@ class RowGapTest { sender = sender, ) + @Test + fun `the unread divider sits a normal gap from either neighbour`() { + val divider = ChatListItem.UnreadDivider(count = 3) + assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), divider, config)) + assertEquals(RowGap.Normal, rowGapBelow(divider, bubble(alice), config)) + } + @Test fun `two members of a group are a run apart`() { assertEquals(RowGap.Wide, rowGapBelow(bubble(alice), bubble(bob), config)) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt new file mode 100644 index 0000000000..508345583b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.shared.chat.UnreadBoundary +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The boundary is resolved once per visit and then only the lifetime policy may clear it. Reading + * the chat must not move it: the list advances the READ pointer as the reader scrolls, and a + * boundary that followed would slide the divider away from what was new at open. + */ +class UnreadBoundaryReducerTest { + + private val at = UnreadBoundary.At(readThrough = 10, count = 30) + + private fun reduce( + state: ChatViewModel.State, + event: ChatViewModel.Event, + lifetime: UnreadDividerLifetime = UnreadDividerLifetime.UntilClose, + ): ChatViewModel.State = ChatViewModel.stateReducer(event, lifetime)(state) + + @Test + fun `a chat opens resolving`() = assertEquals(UnreadBoundary.Resolving, ChatViewModel.State().unreadBoundary) + + @Test + fun `resolution sets the boundary and the walk budget`() { + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.UnreadBoundaryResolved(at, walkBudget = 30)) + + assertEquals(at, state.unreadBoundary) + assertEquals(30, state.unreadWalkBudget) + } + + @Test + fun `reading does not move the boundary`() { + val state = reduce(ChatViewModel.State(unreadBoundary = at), ChatViewModel.Event.AdvanceReadPointer(40)) + + assertEquals(at, state.unreadBoundary) + } + + @Test + fun `until close keeps the divider through a send`() { + val state = reduce(ChatViewModel.State(unreadBoundary = at), ChatViewModel.Event.SendMessage) + + assertEquals(at, state.unreadBoundary) + } + + @Test + fun `until send clears the divider on a send`() { + val state = reduce( + ChatViewModel.State(unreadBoundary = at), + ChatViewModel.Event.SendMessage, + UnreadDividerLifetime.UntilSend, + ) + + assertEquals(UnreadBoundary.None, state.unreadBoundary) + } + + @Test + fun `until send leaves a boundary that is still resolving`() { + val state = reduce( + ChatViewModel.State(), + ChatViewModel.Event.SendMessage, + UnreadDividerLifetime.UntilSend, + ) + + assertEquals(UnreadBoundary.Resolving, state.unreadBoundary) + } + + @Test + fun `the shipped lifetime is until close`() = + assertEquals(UnreadDividerLifetime.UntilClose, UNREAD_DIVIDER_LIFETIME) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt new file mode 100644 index 0000000000..ca0f1ea4ed --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt @@ -0,0 +1,108 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.MessagePart +import com.flipcash.shared.chat.models.SeparatorConfig +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The shared placement table, which iOS encodes too. The boundary each case passes is the one + * [com.flipcash.shared.chat.MessagingOperations.resolveUnreadBoundary] resolves for it; the count + * side of the table is covered against Room in `ChatMessageDaoTest`. + */ +class UnreadDividerPlacementTest { + + private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000) + + private fun row(id: Long, self: Boolean = false, part: MessagePart? = null) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = self, + timestamp = noon, + part = part, + ) + + /** Oldest first, as the table reads; returns the id the divider sits above, or null. */ + private fun dividerAbove(boundary: UnreadBoundary, vararg oldestFirst: ChatListItem.ContentBubble): Long? { + val newestFirst = oldestFirst.reversed() + return newestFirst.indices.firstNotNullOfOrNull { i -> + val newer = newestFirst[i] + val older = newestFirst.getOrNull(i + 1) + newer.messageId.takeIf { unreadDividerBetween(newer, older, boundary) } + } + } + + @Test + fun plain() = assertEquals( + 3L, + dividerAbove(UnreadBoundary.At(2, 2), row(1), row(2, self = true), row(3), row(4)), + ) + + @Test + fun `nothing unread`() = assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2))) + + @Test + fun `only own after pointer`() = + assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2, self = true), row(3, self = true))) + + @Test + fun `read-through deleted`() = assertEquals(3L, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(3))) + + @Test + fun `unread tombstone`() = assertEquals(2L, dividerAbove(UnreadBoundary.At(1, 1), row(1), row(2), row(3))) + + @Test + fun `no self row`() = assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2))) + + @Test + fun `own message first after pointer`() = + assertEquals(3L, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(2, self = true), row(3))) + + @Test + fun `resolving draws nothing`() = assertEquals(null, dividerAbove(UnreadBoundary.Resolving, row(1), row(2))) + + @Test + fun `nothing stored at or below read-through`() = + assertEquals(null, dividerAbove(UnreadBoundary.None, row(3), row(4))) + + @Test + fun `rows of one split message never straddle the divider`() { + val boundary = UnreadBoundary.At(1, 1) + val leading = row(2, part = MessagePart.Leading) + val card = row(2, part = MessagePart.Card) + // Newest first within the message: the card sits under the leading text in reverse layout. + assertEquals(false, unreadDividerBetween(newer = card, older = leading, boundary = boundary)) + assertEquals(true, unreadDividerBetween(newer = leading, older = row(1), boundary = boundary)) + } + + @Test + fun `a date on the divider's gap rides on the divider`() { + val older = row(1).copy(timestamp = noon) + val newer = row(2).copy(timestamp = Instant.fromEpochMilliseconds(noon.toEpochMilliseconds() + 3 * 86_400_000L)) + + assertEquals( + ChatListItem.UnreadDivider(count = 1, date = newer.timestamp), + separatorBetween(newer, older, UnreadBoundary.At(1, 1), SeparatorConfig.DayOnly), + ) + assertEquals( + ChatListItem.UnreadDivider(count = 1, date = null), + separatorBetween(newer.copy(timestamp = noon), older, UnreadBoundary.At(1, 1), SeparatorConfig.DayOnly), + ) + assertEquals( + ChatListItem.DateSeparator(newer.timestamp), + separatorBetween(newer, older, UnreadBoundary.None, SeparatorConfig.DayOnly), + ) + } + + @Test + fun `counts over 99 read 99+`() { + assertEquals("1", unreadCountLabel(1)) + assertEquals("99", unreadCountLabel(99)) + assertEquals("99+", unreadCountLabel(100)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt new file mode 100644 index 0000000000..2ba38a1506 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.messenger.internal + +import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** What the light pipeline stage emits for a fixed page: one item per gap, the divider carrying a date. */ +class UnreadSeparatorsTest { + + private val day1 = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val day2 = day1 + 1.days + + private fun row(id: Long, at: Instant) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = at, + ) + + private suspend fun items(boundary: UnreadBoundary, vararg newestFirst: ChatListItem.ContentBubble) = + flowOf(PagingData.from(newestFirst.toList()).withSeparators(boundary, SeparatorConfig.DayOnly)) + .asSnapshot() + + private val r3 = row(3, day2 + 2.minutes) + private val r2 = row(2, day2) + private val r1 = row(1, day1) + + @Test + fun `divider alone inside a day`() = runTest { + assertEquals( + listOf(r3, ChatListItem.UnreadDivider(1, date = null), r2, r1), + items(UnreadBoundary.At(2, 1), r3, r2, r1).dropDates(), + ) + } + + @Test + fun `date alone when there is no boundary`() = runTest { + assertEquals( + listOf(r3, r2, ChatListItem.DateSeparator(r2.timestamp), r1), + items(UnreadBoundary.None, r3, r2, r1), + ) + } + + @Test + fun `date and divider on one gap are one item`() = runTest { + assertEquals( + listOf(r3, r2, ChatListItem.UnreadDivider(2, date = r2.timestamp), r1), + items(UnreadBoundary.At(1, 2), r3, r2, r1), + ) + } + + @Test + fun `divider above the oldest carries that message's date`() = runTest { + assertEquals( + listOf(r3, r2, ChatListItem.DateSeparator(r2.timestamp), r1, ChatListItem.UnreadDivider(3, date = r1.timestamp)), + items(UnreadBoundary.At(0, 3), r3, r2, r1), + ) + } + + private fun List.dropDates() = filterNot { it is ChatListItem.DateSeparator } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt new file mode 100644 index 0000000000..b880e20622 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.withSeparators +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * Where a chat opens and what it reports read on the way there: 40 inbound messages with the + * viewer's pointer at 10, so the divider sits above message 11, well off the bottom of the screen. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class UnreadDividerOpenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val start = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val advanced = mutableListOf() + + private fun open(boundary: UnreadBoundary) { + val newestFirst = (40L downTo 1L).map { id -> + ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = start + id.minutes, + ) + } + // Built once: a flow made inside composition is a new one each pass, and a new + // LazyPagingItems with it. + // With its load states stated: without them the page arrives but refresh reads Loading + // forever, and the list never counts its first load as done. + val loaded = LoadState.NotLoading(endOfPaginationReached = true) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + MessageList( + contentPadding = PaddingValues(), + state = ChatViewModel.State( + unreadBoundary = boundary, + unreadWalkBudget = (boundary as? UnreadBoundary.At)?.count ?: 0, + ), + messages = paged.collectAsLazyPagingItems(), + separatorConfig = SeparatorConfig.DayOnly, + onAction = { action -> + if (action is ChatAction.AdvanceReadPointer) advanced += action.messageId + }, + linkCardResolution = Unresolved, + canViewProfile = false, + ) + } + } + // The page arrives through the pager's own dispatch, which idling the clock does not wait + // on. + composeRule.waitUntil(5_000) { + (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() } + } + composeRule.waitForIdle() + } + + @Test + fun `opens with the divider at the top and reports nothing below the screen`() { + open(UnreadBoundary.At(readThrough = 10, count = 30)) + + val divider = composeRule.onNodeWithText("30 Unread Messages").assertIsDisplayed() + val rootHeight = composeRule.onRoot().getUnclippedBoundsInRoot().bottom + assertTrue(divider.getUnclippedBoundsInRoot().top < rootHeight / 4, "divider is not near the top") + composeRule.onNodeWithText("m40").assertDoesNotExist() + + assertTrue(advanced.isNotEmpty(), "reads never enabled") + composeRule.onNodeWithText("m${advanced.max()}").assertIsDisplayed() + assertTrue(advanced.max() < 40L, "reported a read past the screen: ${advanced.max()}") + } + + @Test + fun `opens at the bottom with no boundary`() { + open(UnreadBoundary.None) + + composeRule.onNodeWithText("m40").assertIsDisplayed() + assertTrue(40L in advanced) + } + + @Test + fun `reports nothing while the boundary is resolving`() { + open(UnreadBoundary.Resolving) + + assertTrue(advanced.isEmpty(), "reported $advanced before the boundary resolved") + } + + private object Unresolved : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 94a48db35a..dc8bef481d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -16,6 +16,18 @@ sealed interface ChatListItem { override val itemContentType: Any = "date-separator" } + /** + * "N Unread Messages", above the first message that arrived after the viewer last read the + * chat. [date] is set when a day also changes at that gap: the list takes one item per gap, so + * the date is drawn above the divider rather than as a separator of its own. + * + * At most one per transcript, so its key is fixed. + */ + data class UnreadDivider(val count: Int, val date: Instant? = null) : ChatListItem { + override val itemKey: Any = "unread-divider" + override val itemContentType: Any = "unread-divider" + } + data class ContentBubble( val messageId: Long, val contentIndex: Int, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 8b6ba954ba..e56f7a696d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -190,6 +190,16 @@ interface MessagingOperations { */ suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? + /** + * Where the "N unread" divider sits in [chatId], read from the viewer's stored READ pointer. + * [UnreadBoundary.None] when the viewer's own member row is not stored or nothing inbound is + * unread. Read once per visit: later pointer writes must not move it. + */ + suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary + + /** How many stored messages in [chatId] sit past [messageId]. Bounds the walk to the unread divider. */ + suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int + /** Observes the member list for [chatId]. */ fun observeMembers(chatId: ChatId): Flow> diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt new file mode 100644 index 0000000000..e9efb47299 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt @@ -0,0 +1,21 @@ +package com.flipcash.shared.chat + +/** + * Where the "N unread" divider sits in a chat transcript, resolved once per visit from the viewer's + * own READ pointer as stored when the chat opened. + */ +sealed interface UnreadBoundary { + /** Not read yet; the transcript holds read reporting and initial positioning until it resolves. */ + data object Resolving : UnreadBoundary + + /** No divider: nothing inbound is unread, or the viewer's own member row is not stored. */ + data object None : UnreadBoundary + + /** + * The divider sits in the gap straddling [readThrough]: the viewer's READ pointer, moved past any + * of their own messages that directly follow it, so the divider lands above the first message + * someone else sent. It need not be a stored message's id. [count] is the inbound, non-deleted + * messages past the pointer. + */ + data class At(val readThrough: Long, val count: Int) : UnreadBoundary +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 4e690fc6db..19978ddc2e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -33,6 +33,7 @@ import com.flipcash.shared.chat.internal.SenderResolver import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation +import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager @@ -179,6 +180,26 @@ class MessagingDelegate @Inject constructor( override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? = messageDataSource.distanceFromNewest(chatId, messageId) + override suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary { + val selfId = userManager.accountId ?: return UnreadBoundary.None + val readThrough = memberDataSource.getSelfReadPointerOrNull(chatId, selfId) + ?: return UnreadBoundary.None + val count = messageDataSource.countInboundAfter(chatId, selfId, readThrough) + if (count <= 0) return UnreadBoundary.None + // The list places the divider by comparing neighbours, so it cannot see past the viewer's + // own messages right after the pointer. Resolving the boundary past them puts the divider + // above the first message someone else sent. + val firstNotOwn = messageDataSource.firstNotSentByAfter(chatId, selfId, readThrough) + val boundary = firstNotOwn?.minus(1) ?: readThrough + // With nothing stored at or below the boundary, older unread messages may never have been + // fetched, so the count could come out short. No divider beats a wrong one. + if (!messageDataSource.hasAtOrBelow(chatId, boundary)) return UnreadBoundary.None + return UnreadBoundary.At(readThrough = boundary, count = count) + } + + override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int = + messageDataSource.countAfter(chatId, messageId) + override fun observeMembers(chatId: ChatId): Flow> { return memberDataSource.observeMembers(chatId) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt new file mode 100644 index 0000000000..098e052739 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt @@ -0,0 +1,115 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Where the unread divider sits is read once, from the stored self member row. What matters here is + * the missing row: a group's roster is paged, so the viewer's own row may not be stored, and that + * has to read as "nothing known" rather than as a pointer of zero, which would put the divider above + * the whole transcript. + */ +class UnreadBoundaryResolutionTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + + private val memberDataSource = mockk(relaxed = true) + private val messageDataSource = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + } + + init { + coEvery { messageDataSource.hasAtOrBelow(chatId, any()) } returns true + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = mockk(relaxed = true), + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + @Test + fun `no self row is no divider`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns null + + assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) + coVerify(exactly = 0) { messageDataSource.countInboundAfter(any(), any(), any()) } + } + + @Test + fun `nothing unread past the pointer is no divider`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 0 + + assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) + } + + @Test + fun `unread past the pointer places the divider there`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3 + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3 + + assertEquals(UnreadBoundary.At(readThrough = 2, count = 3), delegate.resolveUnreadBoundary(chatId)) + } + + /** + * 1:other 2:self 3:other with the pointer at 1. The divider belongs above 3, and the list places + * it by comparing neighbours, so the boundary has to sit past the viewer's own message. + */ + @Test + fun `the boundary steps over the viewer's own messages after the pointer`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 1 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 1) } returns 1 + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 1) } returns 3 + + assertEquals(UnreadBoundary.At(readThrough = 2, count = 1), delegate.resolveUnreadBoundary(chatId)) + } + + /** + * 3:other 4:other with the pointer at 1. Nothing at or below the boundary is stored, so older + * unread messages may never have been fetched and the count could come out short. + */ + @Test + fun `nothing stored at or below the boundary is no divider`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 1 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 1) } returns 2 + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 1) } returns 3 + coEvery { messageDataSource.hasAtOrBelow(chatId, 2) } returns false + + assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) + } + + @Test + fun `signed out is no divider`() = runTest { + every { userManager.accountId } returns null + + assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) + } + + @Test + fun `the walk budget counts every stored row past the id`() = runTest { + coEvery { messageDataSource.countAfter(chatId, 10) } returns 30 + + assertEquals(30, delegate.countMessagesAfter(chatId, 10)) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt new file mode 100644 index 0000000000..7d7546d8ac --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt @@ -0,0 +1,90 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The shared "does this chat show as unread" table, which iOS encodes too. Unread means the newest + * visible message is past the viewer's READ pointer and someone else sent it. A group with no self + * row is not unread, since its roster is paged; a DM with no self row reads its pointer as zero. + */ +class UnreadTableTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + private fun member(id: List, readPointer: Long? = null) = ChatMember( + userId = id, + userProfile = UserProfile.Empty, + pointers = listOfNotNull( + readPointer?.let { + MessagePointer( + type = PointerType.READ, + userId = id, + value = it, + timestamp = Instant.fromEpochSeconds(1_000), + ) + }, + ), + ) + + private fun unread( + newest: Long?, + fromSelf: Boolean = false, + selfPointer: Long?, + type: ChatType, + ): Boolean { + val members = listOfNotNull(member(otherId), selfPointer?.let { member(selfId, it) }) + val lastMessage = newest?.let { + ChatMessage( + messageId = it, + senderId = if (fromSelf) selfId else otherId, + content = listOf(MessageContent.Text("m$it")), + timestamp = Instant.fromEpochSeconds(it), + unreadSeq = it, + ) + } + val metadata = ChatMetadata( + chatId = ChatId("aabbccdd"), + type = type, + members = members, + lastMessage = lastMessage, + lastActivity = Instant.fromEpochSeconds(2_000), + ) + return unreadCount(metadata, selfId) > 0 + } + + @Test + fun `newest from someone else past the pointer`() = + assertEquals(true, unread(newest = 5, selfPointer = 4, type = ChatType.CONTACT_DM)) + + @Test + fun `newest is your own`() = + assertEquals(false, unread(newest = 5, fromSelf = true, selfPointer = 4, type = ChatType.CONTACT_DM)) + + @Test + fun `pointer at the newest`() = + assertEquals(false, unread(newest = 5, selfPointer = 5, type = ChatType.GROUP)) + + @Test + fun `group with no self row`() = + assertEquals(false, unread(newest = 5, selfPointer = null, type = ChatType.GROUP)) + + @Test + fun `DM with no self row reads the pointer as zero`() = + assertEquals(true, unread(newest = 5, selfPointer = null, type = ChatType.CONTACT_DM)) + + @Test + fun `no messages`() = + assertEquals(false, unread(newest = null, selfPointer = 4, type = ChatType.CONTACT_DM)) +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index b99fcd25e5..ccf717ae4d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -76,6 +76,45 @@ interface ChatMessageDao { throughId: Long, ): List + /** + * How many messages in [chatIdHex] someone else sent after [afterId] that still have content — + * the count the unread divider shows. A range rather than a lookup of [afterId], so the + * read-through message itself need not be stored. + */ + @Query( + "SELECT COUNT(*) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND is_deleted = 0 AND message_id > :afterId" + ) + suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int + + /** + * The first stored message in [chatIdHex] after [afterId] that [selfIdHex] did not send, + * tombstones included, or `null` when only the viewer's own messages follow. The unread divider + * sits above it: the viewer's own messages right after their pointer are not unread. + */ + @Query( + "SELECT MIN(message_id) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex AND message_id > :afterId " + + "AND (sender_id_hex IS NULL OR sender_id_hex != :selfIdHex)" + ) + suspend fun firstNotSentByAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Long? + + /** + * Whether any message in [chatIdHex] at or below [id] is stored, tombstones included. Without one, + * older unread messages may never have been fetched, so a count past [id] can come out short. + */ + @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id <= :id)") + suspend fun hasAtOrBelow(chatIdHex: String, id: Long): Boolean + + /** + * How many stored messages in [chatIdHex] come after [afterId], of any sender and including + * tombstones — every row the transcript draws between the newest message and that id. + */ + @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id > :afterId") + suspend fun countAfter(chatIdHex: String, afterId: Long): Int + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex LIMIT 1") suspend fun getByClientId(chatIdHex: String, clientIdHex: String): ChatMessageEntity? diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index a3a05ebc41..10eb6c0373 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -211,6 +211,119 @@ class ChatMessageDaoTest { assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000)) } + /** + * The unread divider's count, against the cases iOS encodes too: someone else's messages past + * the viewer's READ pointer, without tombstones. The read-through message itself need not be + * stored for the range to count. + */ + private fun from(sender: String, messageId: Long) = text(messageId, "m$messageId").copy(senderIdHex = sender) + + private suspend fun unread(pointer: Long) = dao.countInboundAfter(CHAT_HEX, SELF_HEX, pointer) + + @Test + fun `countInboundAfter counts only others' messages past the pointer`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4))) + + assertEquals(2, unread(pointer = 2)) + } + + @Test + fun `countInboundAfter is zero when nothing is past the pointer`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2))) + + assertEquals(0, unread(pointer = 2)) + } + + @Test + fun `countInboundAfter ignores the viewer's own messages`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3))) + + assertEquals(0, unread(pointer = 1)) + } + + @Test + fun `countInboundAfter counts past a read-through message that is gone`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 3))) + + assertEquals(1, unread(pointer = 2)) + } + + @Test + fun `countInboundAfter skips an unread tombstone`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2), from(SENDER_HEX, 3))) + dao.upsert(tombstone(2)) + + assertEquals(1, unread(pointer = 1)) + } + + @Test + fun `countInboundAfter counts past the viewer's own first message`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3))) + + assertEquals(1, unread(pointer = 1)) + } + + @Test + fun `countInboundAfter ignores senderless rows and other chats`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null))) + dao.upsert(from(SENDER_HEX, 3).copy(chatIdHex = OTHER_HEX)) + + assertEquals(0, unread(pointer = 1)) + } + + private suspend fun firstNotOwn(pointer: Long) = dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, pointer) + + @Test + fun `firstNotSentByAfter is the first message past the pointer`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4))) + + assertEquals(3L, firstNotOwn(pointer = 2)) + } + + @Test + fun `firstNotSentByAfter steps over the viewer's own messages after the pointer`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3), from(SENDER_HEX, 4))) + + assertEquals(4L, firstNotOwn(pointer = 1)) + } + + @Test + fun `firstNotSentByAfter lands on an unread tombstone`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2), from(SENDER_HEX, 3))) + dao.upsert(tombstone(2)) + + assertEquals(2L, firstNotOwn(pointer = 1)) + } + + @Test + fun `firstNotSentByAfter is null when only the viewer's messages follow`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3))) + dao.upsert(from(SENDER_HEX, 9).copy(chatIdHex = OTHER_HEX)) + + assertEquals(null, firstNotOwn(pointer = 1)) + } + + @Test + fun `hasAtOrBelow sees a stored row at or below the id, tombstones included`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 3), from(SENDER_HEX, 4))) + dao.upsert(from(SENDER_HEX, 1).copy(chatIdHex = OTHER_HEX)) + + assertEquals(false, dao.hasAtOrBelow(CHAT_HEX, 2)) + assertEquals(true, dao.hasAtOrBelow(CHAT_HEX, 3)) + + dao.upsert(tombstone(3)) + assertEquals(true, dao.hasAtOrBelow(CHAT_HEX, 3)) + } + + @Test + fun `countAfter counts every stored row past the id`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4))) + dao.upsert(tombstone(3)) + dao.upsert(from(SENDER_HEX, 9).copy(chatIdHex = OTHER_HEX)) + + assertEquals(3, dao.countAfter(CHAT_HEX, 1)) + } + /** * Killing the app mid-send leaves the row `SENDING` with nothing alive to move it: failure is * only ever written from the `onFailure` of the coroutine that issued the send. The sweep is @@ -256,6 +369,7 @@ class ChatMessageDaoTest { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" const val SENDER_HEX = "1122" + const val SELF_HEX = "3344" const val CLIENT_HEX = "eeff" } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index a3e753e2eb..4881470076 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -50,12 +50,17 @@ class ChatMemberDataSource @Inject constructor( /** The READ pointer [selfId] has already advanced to in [chatId], or 0 if none is cached. */ suspend fun getSelfReadPointer(chatId: ChatId, selfId: ID): Long = + getSelfReadPointerOrNull(chatId, selfId) ?: 0L + + /** + * The READ pointer [selfId] has already advanced to in [chatId]: 0 when the self row carries + * none, `null` when there is no self row. A group's roster is paged, so a missing row means + * nothing is known, not that nothing has been read. + */ + suspend fun getSelfReadPointerOrNull(chatId: ChatId, selfId: ID): Long? = getMembersForChat(chatId) .firstOrNull { it.userId == selfId } - ?.pointers - ?.firstOrNull { it.type == PointerType.READ } - ?.value - ?: 0L + ?.let { self -> self.pointers.firstOrNull { it.type == PointerType.READ }?.value ?: 0L } suspend fun upsert(chatId: ChatId, members: List) { val database = db ?: return diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 09597c3b39..9341ed9a62 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -147,6 +147,30 @@ class ChatMessageDataSource @Inject constructor( return dao.countNewerThan(hex, stored.timestampEpochMs) } + /** Stored, non-deleted messages in [chatId] past [afterId] that someone other than [selfId] sent. */ + suspend fun countInboundAfter(chatId: ChatId, selfId: ID, afterId: Long): Int = + db?.chatMessageDao()?.countInboundAfter( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + ) ?: 0 + + /** The first stored message in [chatId] past [afterId] that [selfId] did not send, or `null`. */ + suspend fun firstNotSentByAfter(chatId: ChatId, selfId: ID, afterId: Long): Long? = + db?.chatMessageDao()?.firstNotSentByAfter( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + ) + + /** Whether any message in [chatId] at or below [id] is stored, deleted or not. */ + suspend fun hasAtOrBelow(chatId: ChatId, id: Long): Boolean = + db?.chatMessageDao()?.hasAtOrBelow(mapper.chatIdHex(chatId), id) ?: false + + /** Every stored message in [chatId] past [afterId], deleted or not. */ + suspend fun countAfter(chatId: ChatId, afterId: Long): Int = + db?.chatMessageDao()?.countAfter(mapper.chatIdHex(chatId), afterId) ?: 0 + suspend fun getInboundMessagesInRange( chatId: ChatId, selfId: ID,