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 eef7b44b7..949567b71 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 @@ -1193,7 +1193,7 @@ internal class ChatViewModel @AssistedInject constructor( * separator config re-runs only [messages]' separator pass, not the per-message lookups here. */ @OptIn(ExperimentalCoroutinesApi::class) - private val mappedMessages: Flow> = + private val mappedMessages: Flow> = combine( messageStream, pendingMutations, @@ -1207,7 +1207,15 @@ internal class ChatViewModel @AssistedInject constructor( } pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) - message.content.flatMapIndexed { index, content -> + message.content.flatMapIndexed { index, content -> + // A notice has no sender, receipt, capabilities or link card, so it skips all of + // that and becomes its own kind of row. + if (content is MessageContent.System) { + return@flatMapIndexed listOfNotNull( + systemMessageItem(message.messageId, index, message.timestamp, content), + ) + } + val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token if (token != null) { @@ -1356,7 +1364,9 @@ internal class ChatViewModel @AssistedInject constructor( .let { pages -> combine(pages, reactionOverlay) { data, overlay -> if (overlay.isEmpty()) return@combine data - data.map { bubble -> + data.map { item -> + if (item !is ChatListItem.ContentBubble) return@map item + val bubble = item val live = overlay[bubble.messageId] ?: return@map bubble bubble.copy( // Only the row the "Edited" marker also lands on carries the pill row, so a @@ -1538,9 +1548,9 @@ internal class ChatViewModel @AssistedInject constructor( * Nothing reads from this presenter, so it never asks for more than the first page. */ private fun initTranscriptPrefetch() { - val presenter = object : PagingDataPresenter(dispatchers.Default) { + val presenter = object : PagingDataPresenter(dispatchers.Default) { override suspend fun presentPagingDataEvent( - event: PagingDataEvent, + event: PagingDataEvent, ) = Unit } pagingScope.launch { mappedMessages.collectLatest { presenter.collectFrom(it) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt new file mode 100644 index 000000000..207d07321 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt @@ -0,0 +1,25 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.displayText +import kotlin.time.Instant + +/** + * The transcript row for one System content of a message, or `null` when it has no text to draw. + * The null case is a notice stored before the mapper dropped blank ones; it produces no row at all. + */ +internal fun systemMessageItem( + messageId: Long, + contentIndex: Int, + timestamp: Instant, + content: MessageContent.System, +): ChatListItem.SystemMessage? { + val text = content.displayText() ?: return null + return ChatListItem.SystemMessage( + messageId = messageId, + contentIndex = contentIndex, + timestamp = timestamp, + text = text, + ) +} 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 index 2c1340c91..e75c27317 100644 --- 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 @@ -30,11 +30,13 @@ internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose * the divider; otherwise a transcript would draw a second one under the same fixed key. */ internal fun unreadDividerBetween( - newer: ChatListItem.ContentBubble, - older: ChatListItem.ContentBubble?, + newer: ChatListItem.TranscriptMessage, + older: ChatListItem.TranscriptMessage?, boundary: UnreadBoundary, ): Boolean { if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false + // A notice is treated as already read: it is never counted, so it never opens the unread run. + if (newer is ChatListItem.SystemMessage) return false if (older != null && older.messageId < 1) return false val olderId = older?.messageId ?: Long.MIN_VALUE return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId @@ -47,8 +49,8 @@ internal fun unreadDividerBetween( * its last row takes the Encrypted marker, carrying whatever separator the gap would have had. */ internal fun separatorBetween( - newer: ChatListItem.ContentBubble?, - older: ChatListItem.ContentBubble?, + newer: ChatListItem.TranscriptMessage?, + older: ChatListItem.TranscriptMessage?, boundary: UnreadBoundary, config: SeparatorConfig, oldestEncryptedId: Long? = null, @@ -64,8 +66,8 @@ internal fun separatorBetween( } private fun plainSeparatorBetween( - newer: ChatListItem.ContentBubble, - older: ChatListItem.ContentBubble?, + newer: ChatListItem.TranscriptMessage, + older: ChatListItem.TranscriptMessage?, boundary: UnreadBoundary, config: SeparatorConfig, ): ChatListItem? { @@ -81,11 +83,11 @@ private fun plainSeparatorBetween( } else null } -internal fun PagingData.withSeparators( +internal fun PagingData.withSeparators( boundary: UnreadBoundary, config: SeparatorConfig, oldestEncryptedId: Long? = null, ): PagingData = - insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? -> + insertSeparators { newer: ChatListItem.TranscriptMessage?, older: ChatListItem.TranscriptMessage? -> separatorBetween(newer, older, boundary, config, oldestEncryptedId) } 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 7285a4f81..c68cbc400 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 @@ -649,7 +649,7 @@ internal fun MessageList( if (messages.itemCount > 0) { val oldest = messages.peek(messages.itemCount - 1) val oldestTimestamp = when (oldest) { - is ChatListItem.ContentBubble -> oldest.timestamp + is ChatListItem.TranscriptMessage -> oldest.timestamp is ChatListItem.DateSeparator -> null // already a separator is ChatListItem.UnreadDivider -> null // carries the oldest message's date is ChatListItem.EncryptedMarker -> null // carries it too, as `above` @@ -680,7 +680,7 @@ internal fun MessageList( LaunchedEffect(listState, messages) { snapshotFlow { if (messages.itemCount == 0) null - else (messages.peek(0) as? ChatListItem.ContentBubble)?.itemKey + else (messages.peek(0) as? ChatListItem.TranscriptMessage)?.itemKey } .filterNotNull() .distinctUntilChanged() @@ -700,7 +700,7 @@ internal fun MessageList( listState.firstVisibleItemScrollOffset == 0 // Always scroll for own messages; only near-bottom for incoming val nearBottom = listState.firstVisibleItemIndex <= 5 - val newest = messages.peek(0) as? ChatListItem.ContentBubble + val newest = messages.peek(0) as? ChatListItem.TranscriptMessage val editing = editingMessageId if (editing != null) { // A message arriving mid-edit still moves the transcript, or it would land 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 deb5ddb38..a8be264ea 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 @@ -43,9 +43,10 @@ internal fun HandleMessageReads( var highestId = 0L for (info in visibleRange) { if (info.index !in 0 until count) continue - val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue - if (!bubble.isFromSelf && bubble.messageId > highestId) { - highestId = bubble.messageId + // A notice counts as read once seen, or the pointer would stall under a newest one. + val message = messages.peek(info.index) as? ChatListItem.TranscriptMessage ?: continue + if (!message.isFromSelf && message.messageId > highestId) { + highestId = message.messageId } } if (highestId > 0L) highestId else null 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 3b4aaac47..75b696943 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 @@ -301,6 +301,10 @@ internal fun MessageRow( ) } + is ChatListItem.SystemMessage -> Box(insertionModifier) { + SystemMessageRow(item.text) + } + is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) // Bound to a local: `sender` is a property of another module's public API, so diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt new file mode 100644 index 000000000..a38673627 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.getcode.theme.CodeTheme + +/** + * A server notice, centred in the transcript (node 11721:879, option D, file Jxf2wD9QyfKaONbhHSdU0K): + * an info glyph, then the text. No fill, no bubble and no sender label. The glyph is what tells it + * apart from the date separator, which is also centred caption text. + * + * The text is drawn as it arrived: it wraps at [SYSTEM_TEXT_MAX_WIDTH], centred, with no `maxLines` + * and no trimming. The glyph stays beside the first line. + */ +@Composable +internal fun SystemMessageRow(text: String, modifier: Modifier = Modifier) { + val lineHeight = with(LocalDensity.current) { CodeTheme.typography.caption.lineHeight.toDp() } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = SYSTEM_ROW_VERTICAL_PADDING) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(SYSTEM_GLYPH_GAP, Alignment.CenterHorizontally), + ) { + // One line tall, so the glyph centres on the first line however many the text wraps to. + Box(modifier = Modifier.height(lineHeight), contentAlignment = Alignment.Center) { + Icon( + modifier = Modifier.size(SYSTEM_GLYPH_SIZE), + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + Text( + modifier = Modifier.widthIn(max = SYSTEM_TEXT_MAX_WIDTH), + text = text, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +private val SYSTEM_GLYPH_SIZE = 14.dp +private val SYSTEM_GLYPH_GAP = 6.dp +private val SYSTEM_TEXT_MAX_WIDTH = 250.dp +private val SYSTEM_ROW_VERTICAL_PADDING = 6.dp + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_SystemMessageRow() { + Column(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) { + SystemMessageRow("Alex joined the group") + SystemMessageRow("Alex added Sam, Jordan and Riley to the group, and changed the group name to Weekend plans") + } +} 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 8a5bd22ab..cf74f4b35 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 @@ -92,4 +92,13 @@ class RowGapTest { fun `a status line under a bubble ends the run with a normal gap`() { assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), bubble(alice), config, carriesReceipt = true)) } + + @Test + fun `a notice sits a normal gap from either neighbour`() { + val notice = ChatListItem.SystemMessage(messageId = 2L, contentIndex = 0, timestamp = noon, text = "Sam left") + // Same sender and close in time would be Tight if the notice were a bubble from them. + assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), notice, config)) + assertEquals(RowGap.Normal, rowGapBelow(notice, bubble(alice), config)) + assertEquals(RowGap.Normal, rowGapBelow(notice, notice, config)) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt index ed4087d34..f9c04b77d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt @@ -76,4 +76,18 @@ class SenderRunTest { assertTrue(startsSenderRun(unnamedAlice, older = unnamedBob)) assertFalse(startsSenderRun(unnamedAlice, older = unnamedAlice)) } + + private val notice = ChatListItem.SystemMessage( + messageId = 2L, + contentIndex = 0, + timestamp = Instant.fromEpochMilliseconds(1_000), + text = "Sam left the group", + ) + + @Test + fun `a notice between two bubbles from one sender splits the run`() { + // The bubble above the notice is Alice's, but the notice is not: the later bubble restarts + // the run and wears the name and picture again. + assertTrue(startsSenderRun(bubble(alice), older = notice)) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SystemMessageItemTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SystemMessageItemTest.kt new file mode 100644 index 000000000..afc7d52cf --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SystemMessageItemTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.isSystemBlank +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** The shared fixture with iOS: what a system notice becomes in the transcript, and what is blank. */ +class SystemMessageItemTest { + + private val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + + private fun item(text: String, id: Long = 7, index: Int = 0) = + systemMessageItem(id, index, at, MessageContent.System(text)) + + @Test + fun `a notice becomes exactly one row with its own text`() { + val row = assertNotNull(item("Alex joined the group")) + assertEquals("Alex joined the group", row.text) + assertEquals("sys-7", row.itemKey) + assertEquals("system-message", row.itemContentType) + assertEquals(7L, row.messageId) + assertEquals(at, row.timestamp) + assertFalse(row.isFromSelf) + } + + @Test + fun `a second content of one message gets a suffixed key`() { + assertEquals("sys-7-1", assertNotNull(item("Sam left", index = 1)).itemKey) + assertEquals("sys-7", assertNotNull(item("Sam left", index = 0)).itemKey) + } + + @Test + fun `blank text produces no row`() { + listOf("", " ", "\n\t", "\u0085", "\u00A0", "\u3000").forEach { blank -> + assertNull(item(blank), "expected no row for ${blank.map { it.code }}") + } + } + + @Test + fun `the unit separator is not blank`() { + // Kotlin's isWhitespace() treats U+001C-001F as space and U+0085 as not; iOS is the reverse. + assertFalse("\u001F".isSystemBlank()) + assertTrue("\u0085".isSystemBlank()) + assertNotNull(item("\u001F")) + } + + @Test + fun `text that is not blank is kept unmodified`() { + assertEquals(" Alex joined \n", assertNotNull(item(" Alex joined \n")).text) + } + + @Test + fun `the shared whitespace set is blank and nothing else near it is`() { + val whitespace = (0x09..0x0D) + 0x20 + 0x85 + 0xA0 + 0x1680 + (0x2000..0x200A) + + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000 + whitespace.forEach { assertTrue(it.toChar().toString().isSystemBlank(), "U+%04X".format(it)) } + listOf(0x08, 0x0E, 0x1C, 0x1F, 0x21, 0x180E, 0x200B, 0x2060, 0xFEFF).forEach { + assertFalse(it.toChar().toString().isSystemBlank(), "U+%04X".format(it)) + } + } +} 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 index 559881f4c..c7b8f3e26 100644 --- 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 @@ -18,6 +18,13 @@ class UnreadDividerPlacementTest { private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000) + private fun system(id: Long) = ChatListItem.SystemMessage( + messageId = id, + contentIndex = 0, + timestamp = noon, + text = "Alex joined the group", + ) + private fun row(id: Long, self: Boolean = false, part: MessagePart? = null) = ChatListItem.ContentBubble( messageId = id, contentIndex = 0, @@ -28,7 +35,7 @@ class UnreadDividerPlacementTest { ) /** 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? { + private fun dividerAbove(boundary: UnreadBoundary, vararg oldestFirst: ChatListItem.TranscriptMessage): Long? { val newestFirst = oldestFirst.reversed() return newestFirst.indices.firstNotNullOfOrNull { i -> val newer = newestFirst[i] @@ -116,4 +123,17 @@ class UnreadDividerPlacementTest { separatorBetween(newer, older, UnreadBoundary.None, SeparatorConfig.DayOnly), ) } + + @Test + fun `a notice after the pointer never takes the divider`() { + assertEquals(false, unreadDividerBetween(newer = system(3), older = row(2), boundary = UnreadBoundary.At(2, 1))) + assertEquals(null, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(2), system(3))) + } + + @Test + fun `the divider lands below a notice that follows the pointer`() { + // Pointer at 2, then a notice, then the first counted message: firstNotSentByAfter now skips + // the notice, so the resolved boundary is 3 and the divider sits above 4, under the notice. + assertEquals(4L, dividerAbove(UnreadBoundary.At(3, 1), row(1), row(2), system(3), row(4))) + } } 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 index 2ba38a150..ba9943a65 100644 --- 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 @@ -28,7 +28,7 @@ class UnreadSeparatorsTest { timestamp = at, ) - private suspend fun items(boundary: UnreadBoundary, vararg newestFirst: ChatListItem.ContentBubble) = + private suspend fun items(boundary: UnreadBoundary, vararg newestFirst: ChatListItem.TranscriptMessage) = flowOf(PagingData.from(newestFirst.toList()).withSeparators(boundary, SeparatorConfig.DayOnly)) .asSnapshot() @@ -69,4 +69,13 @@ class UnreadSeparatorsTest { } private fun List.dropDates() = filterNot { it is ChatListItem.DateSeparator } + + @Test + fun `a notice that starts a new day gets a date separator above it`() = runTest { + val notice = ChatListItem.SystemMessage(messageId = 3, contentIndex = 0, timestamp = day2, text = "Alex joined") + assertEquals( + listOf(notice, ChatListItem.DateSeparator(day2), r1), + items(UnreadBoundary.None, notice, r1), + ) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt index ea9e089f3..79ef8c83a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -118,4 +118,18 @@ class ReceiptHandoffTest { val rows = listOf(send, mine(1, ReceiptStatus.SENT)) assertEquals(listOf(false, true), shown(rows, settlingKey = send.messageKey, read = readUpTo(1))) } + + @Test + fun `a notice takes no receipt and does not take one from a send`() { + val notice = ChatListItem.SystemMessage(messageId = 3, contentIndex = 0, timestamp = Instant.fromEpochSeconds(180), text = "Sam left") + val rows: List = listOf(notice, mine(2, ReceiptStatus.SENT), mine(1, ReceiptStatus.SENT)) + // Newest first. The notice is newer than both sends and takes no line itself; the send just + // below it keeps the line, and the older send still yields to it. + assertEquals( + listOf(true, false), + listOf(1, 2).map { i -> + shouldShowReceiptLabel(i, rows[i] as ChatListItem.ContentBubble, { rows.getOrNull(it) }, null, null) + }, + ) + } } 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 index 1ddcd7f98..4bb3ed18e 100644 --- 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 @@ -63,7 +63,7 @@ class UnreadDividerOpenTest { // 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 page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) composeRule.setContent { FlipcashThemeWrapper().Wrap { 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 0d15fffe6..0f1596fde 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 @@ -48,16 +48,46 @@ sealed interface ChatListItem { override val itemContentType: Any = "encrypted-marker" } + /** + * A row that stands for one message in the transcript: a [ContentBubble], or a [SystemMessage] + * notice. Separators, auto-scroll and the read pointer care about the message, not how it is + * drawn; sender runs, receipts and bubble corners are bubble-only and cast to [ContentBubble]. + */ + sealed interface TranscriptMessage : ChatListItem { + val messageId: Long + val timestamp: Instant + val isFromSelf: Boolean + } + + /** + * A server notice such as "Alex joined the group": no bubble, no sender and no gestures. + * + * Not a [ContentBubble], so every `as? ContentBubble` that groups runs, places receipts or + * rounds corners passes over it without comparing senders. [contentIndex] is non-zero only for + * the second and later contents of one message, which keeps the key unique. + */ + data class SystemMessage( + override val messageId: Long, + val contentIndex: Int, + override val timestamp: Instant, + val text: String, + ) : TranscriptMessage { + override val isFromSelf: Boolean = false + override val itemKey: Any = + if (contentIndex == 0) "sys-$messageId" else "sys-$messageId-$contentIndex" + override val itemContentType: Any = "system-message" + } + /** Whether this is the unread divider, alone or drawn above the Encrypted marker. */ val holdsUnreadDivider: Boolean get() = this is UnreadDivider || (this is EncryptedMarker && above is UnreadDivider) data class ContentBubble( - val messageId: Long, + override val messageId: Long, val contentIndex: Int, val content: MessageContent, - val isFromSelf: Boolean, - val timestamp: Instant, + override val isFromSelf: Boolean, + override val timestamp: Instant, val receiptStatus: ReceiptStatus? = null, val pendingClientIdHex: String? = null, /** Drives the corner-pinned "Edited" marker. */ @@ -153,7 +183,7 @@ sealed interface ChatListItem { val undecryptableHint: UndecryptableHint = UndecryptableHint.UpdateApp, /** What a photo message needs beyond its content to load and gate its photo. */ val photo: PhotoMessageContext? = null, - ) : ChatListItem { + ) : TranscriptMessage { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the * pipeline supplies it; [sender] covers a caller that builds a bubble from a resolved @@ -220,6 +250,7 @@ sealed interface ChatListItem { is MessageContent.Cash -> "cash-bubble" is MessageContent.Media -> "media" is MessageContent.Reply -> "reply-message" + // Unreachable: System content is emitted as a SystemMessage, not a bubble. is MessageContent.System -> "system-message" is MessageContent.Widget -> "widget-message" is MessageContent.Encrypted -> "encrypted-message" diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt new file mode 100644 index 000000000..8179de0fb --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt @@ -0,0 +1,16 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.isSystemBlank + +/** + * The text a system notice draws and previews as, or `null` when it has none. + * + * The one place the notice's wording is chosen. When the server sends a structured event this + * client can localise, it becomes `when (event) { known -> localised; else -> fallbackText }` here, + * with a resource lookup as a parameter, and no caller changes. + * + * Blank is [isSystemBlank], the definition iOS shares. Text that is not blank is returned as it + * arrived: not trimmed. Rows stored before the mapper dropped blank notices still reach this. + */ +fun MessageContent.System.displayText(): String? = fallbackText.takeUnless { it.isSystemBlank() } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index fc6622756..50fa9eb8b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.handle +import com.flipcash.shared.chat.models.displayText import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.media.ChatMediaText import com.getcode.opencode.model.core.ID @@ -191,7 +192,8 @@ private fun MessageContent.previewText( else -> message } } - is MessageContent.System -> null + // The notice's own words, with no You: prefix and no sender name; it has no sender. + is MessageContent.System -> displayText() // A widget this client can't draw previews as nothing, as an undecryptable message does. is MessageContent.Widget -> when (widget) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 5a82541c2..e4c748fe1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -107,12 +107,11 @@ fun transcriptBubbleMaxWidth(rowWidth: Dp, rowInset: Dp): Dp = /** * The widest a bubble carrying [content] grows in a row [rowWidth] wide, inset by [rowInset] on each - * side. A system notice spans the row, and a cash card keeps its own narrower fraction of the inset + * side. A cash card keeps its own narrower fraction of the inset * row (iOS's `ChatViewController.cashCardWidthFraction` matches it); everything else takes * [transcriptBubbleMaxWidth]. */ fun bubbleMaxWidth(content: MessageContent, rowWidth: Dp, rowInset: Dp): Dp = when (content) { - is MessageContent.System -> rowWidth is MessageContent.Cash -> rowWidth * CASH_CARD_WIDTH_FRACTION else -> transcriptBubbleMaxWidth(rowWidth = rowWidth, rowInset = rowInset) } @@ -308,6 +307,7 @@ fun ContentBubble( onLongClick = onLongClick?.takeIf { interactive }, onDoubleClick = onDoubleClick?.takeIf { interactive }, ) + // Unreachable: the transcript emits System content as ChatListItem.SystemMessage. is MessageContent.System -> Unit // A widget variant this client doesn't know draws as any message it can't show. is MessageContent.Widget -> when (val widget = content.widget) { diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt index 41dbfd1bf..bc8bd9420 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt @@ -46,7 +46,10 @@ class ChatListItemContentTypeTest { @Test fun `cash keeps a content type of its own`() { assertEquals("text-bubble", bubble(MessageContent.Text("hello")).itemContentType) - assertEquals("system-message", bubble(MessageContent.System("joined")).itemContentType) + assertEquals( + "system-message", + ChatListItem.SystemMessage(messageId = 1, contentIndex = 0, timestamp = sentAt, text = "joined").itemContentType, + ) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt index 353451f3b..b3a04181b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt @@ -182,4 +182,19 @@ class BubblePositionTest { positionsOf(items), ) } + + @Test + fun `a notice between two bubbles from one sender solos both`() { + val notice = ChatListItem.SystemMessage(messageId = 2, contentIndex = 0, timestamp = start + 10.seconds, text = "Sam left") + val items: List = listOf( + bubble(3, secondsIn = 20, sender = alice, senderId = listOf(1)), + notice, + bubble(1, secondsIn = 0, sender = alice, senderId = listOf(1)), + ) + + assertEquals( + listOf(BubblePosition.Solo, BubblePosition.Solo), + listOf(0, 2).map { bubblePositionOf(it, items[it] as ChatListItem.ContentBubble, items, SeparatorConfig.DayOnly) }, + ) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 2ead5e583..51dd56713 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -382,4 +382,17 @@ class ChatSummaryPreviewTest { fun `a widget this client can't draw has nothing to preview`() { assertNull(preview(listOf(MessageContent.Widget(WidgetContent.Unsupported)))) } + + @Test + fun `a group notice previews as its own text with no prefix`() { + assertEquals( + "Alex joined the group", + groupReference(listOf(MessageContent.System("Alex joined the group")), senderId = null).lastMessagePreview, + ) + } + + @Test + fun `a notice with blank text has no preview`() { + assertNull(groupReference(listOf(MessageContent.System(" \u0085")), senderId = null).lastMessagePreview) + } } 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 0e513d85d..017d28d3b 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 @@ -37,7 +37,9 @@ interface ChatMessageDao { suspend fun getLatest(chatIdHex: String): ChatMessageEntity? /** - * The newest message that still has content — tombstones skipped. + * The newest message that still has content — tombstones skipped, and so is a message whose + * content list is empty (`'[]'`; a NULL column too), such as a system notice with no text. + * Nothing renders such a message, so it must not become a blank preview or an unread dot. * * This is what the conversation list previews and what its unread check reads, so deleting the * newest message falls the row back to the one before it instead of reading "Message deleted". @@ -45,7 +47,7 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND content_json IS NOT NULL AND content_json != '[]' AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? /** @@ -59,6 +61,7 @@ interface ChatMessageDao { "SELECT * FROM chat_messages WHERE rowid IN (" + "SELECT (SELECT m.rowid FROM chat_messages m " + "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" @@ -74,6 +77,7 @@ interface ChatMessageDao { "SELECT * FROM chat_messages WHERE rowid IN (" + "SELECT (SELECT m.rowid FROM chat_messages m " + "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" @@ -111,11 +115,14 @@ interface ChatMessageDao { * 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. + * + * Senderless rows, such as system notices, are stepped over like the viewer's own, matching + * [countInboundAfter], so the divider lands above the first message that was counted. */ @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)" + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex" ) suspend fun firstNotSentByAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Long? 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 16904b7b0..57397b557 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 @@ -868,4 +868,88 @@ class ChatMessageDaoTest { const val CLIENT_HEX = "eeff" const val SECOND_CLIENT_HEX = "ef01" } + + private fun emptyContent(messageId: Long) = text(messageId, "").copy(contentJson = emptyList(), senderIdHex = null) + + @Test + fun `an empty content list is stored as a bracket pair`() = runTest { + dao.upsert(emptyContent(1)) + + val stored = db.openHelper.readableDatabase + .query("SELECT content_json FROM chat_messages WHERE message_id = 1") + .use { it.moveToFirst(); it.getString(0) } + assertEquals("[]", stored) + } + + @Test + fun `getLatestVisible skips a newest row with empty content and previews the one before`() = runTest { + dao.upsert(listOf(text(1, "one"), text(2, "two"), emptyContent(3))) + + assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) + // getLatest keeps the read anchor on the empty row, so the pointer can still reach it. + assertEquals(3L, dao.getLatest(CHAT_HEX)?.messageId) + } + + @Test + fun `getLatestVisible skips a row whose content column is null`() = runTest { + dao.upsert(listOf(text(1, "one"), text(2, "two").copy(contentJson = null))) + + assertEquals(1L, dao.getLatestVisible(CHAT_HEX)?.messageId) + } + + @Test + fun `getLatestVisible is null when every message has empty content`() = runTest { + dao.upsert(listOf(emptyContent(1), emptyContent(2))) + + assertNull(dao.getLatestVisible(CHAT_HEX)) + } + + @Test + fun `the all-chats latest-visible queries skip empty content too`() = runTest { + dao.upsert(listOf(text(1, "one"), emptyContent(2))) + dao.upsert(listOf(emptyContent(3).copy(chatIdHex = OTHER_HEX))) + + assertEquals(listOf(1L), dao.getLatestVisibleForAllChats().map { it.messageId }) + assertEquals(listOf(1L), dao.observeLatestVisibleForAllChats().first().map { it.messageId }) + } + + @Test + fun `a system notice with text is the latest visible message`() = runTest { + dao.upsert(listOf(text(1, "one"), text(2, "x").copy( + senderIdHex = null, + contentJson = listOf(MessageContentSerialized.System("Alex joined the group")), + ))) + + assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) + } + + @Test + fun `firstNotSentByAfter skips a senderless row`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) + + assertEquals(3L, firstNotOwn(pointer = 1)) + } + + @Test + fun `only senderless rows past the pointer count nothing and place no divider`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null))) + + assertEquals(0, unread(pointer = 1)) + assertNull(firstNotOwn(pointer = 1)) + } + + @Test + fun `firstNotSentByAfter returns a tombstone from another member`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1), tombstone(2).copy(senderIdHex = SENDER_HEX), from(SENDER_HEX, 3))) + + assertEquals(2L, firstNotOwn(pointer = 1)) + } + + @Test + fun `the divider lands below a senderless row that follows the pointer`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) + + assertEquals(3L, firstNotOwn(pointer = 1)) + assertEquals(1, unread(pointer = 1)) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index a230f6041..23a32a7c3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -46,6 +46,7 @@ import com.flipcash.services.models.chat.ImageMetadata import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.isSystemBlank import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate @@ -267,7 +268,10 @@ internal fun MessagingModel.Message.toChatMessage(): ChatMessage { return ChatMessage( messageId = messageId.value, senderId = if (hasSenderId()) senderId.toId() else null, - content = contentList.map { it.toMessageContent() }, + // A notice with no text has nothing to draw. The content goes and the message stays: read + // pointers, paging and the unread divider anchor on message ids. + content = contentList.map { it.toMessageContent() } + .filterNot { it is MessageContent.System && it.fallbackText.isSystemBlank() }, timestamp = Instant.fromEpochSeconds(ts.seconds, ts.nanos), unreadSeq = unreadSeq, lastEditedTs = if (hasLastEditedTs()) Instant.fromEpochSeconds(lastEditedTs.seconds, lastEditedTs.nanos) else null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 00dceaf24..149ec0b2a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -33,6 +33,10 @@ sealed interface MessageContent { * recognise arrives as [WidgetContent.Unsupported] and is rendered as an unsupported message. */ data class Widget(val widget: WidgetContent) : MessageContent + /** + * A notice the server wrote about the chat, such as "Alex joined the group". [fallbackText] is + * the only part this client reads; see `displayText` in chat-ui for what a row draws. + */ data class System(val fallbackText: String) : MessageContent data class Deleted( val deletedTs: Instant, @@ -65,3 +69,20 @@ sealed interface MessageContent { "Encrypted(scheme=$scheme, nonce=${nonce.size} bytes, ciphertext=${ciphertext.size} bytes)" } } + +/** + * Whether this text is nothing but white space, by the definition iOS shares + * (`CharacterSet.whitespacesAndNewlines`): the Unicode White_Space property, U+0009-000D, U+0020, + * U+0085, U+00A0, U+1680, U+2000-200A, U+2028, U+2029, U+202F, U+205F and U+3000. An empty string is + * blank. + * + * Not `String.isBlank()`: Kotlin's `Char.isWhitespace()` counts U+001C-001F, which iOS does not, and + * leaves out U+0085, which it does. A notice must be drawn or dropped the same way on both. + */ +fun String.isSystemBlank(): Boolean = all { it.isSystemWhitespace() } + +private fun Char.isSystemWhitespace(): Boolean = when (code) { + in 0x0009..0x000D, 0x0020, 0x0085, 0x00A0, 0x1680, in 0x2000..0x200A, + 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 -> true + else -> false +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/SystemContentMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/SystemContentMappingTest.kt new file mode 100644 index 000000000..9ef90a9b6 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/SystemContentMappingTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.models.chat.MessageContent +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The server's `SystemContent.fallback_text` reaching the app as a notice, and blank ones not. */ +class SystemContentMappingTest { + + private fun system(text: String) = MessagingModel.Content.newBuilder() + .setSystem(MessagingModel.SystemContent.newBuilder().setFallbackText(text)) + .build() + + private fun message(vararg contents: MessagingModel.Content) = MessagingModel.Message.newBuilder() + .setMessageId(MessagingModel.MessageId.newBuilder().setValue(7)) + .addAllContent(contents.toList()) + .build() + + @Test + fun `fallback text maps to a system content`() { + assertEquals( + MessageContent.System("Alex joined the group"), + system("Alex joined the group").toMessageContent(), + ) + } + + @Test + fun `a notice keeps its content`() { + assertEquals( + listOf(MessageContent.System("Alex joined the group")), + message(system("Alex joined the group")).toChatMessage().content, + ) + } + + @Test + fun `blank notices drop their content and keep the message`() { + listOf("", " ", "\n\t", "\u0085", "\u00A0").forEach { blank -> + val mapped = message(system(blank)).toChatMessage() + assertEquals(emptyList(), mapped.content, "blank ${blank.map { it.code }}") + assertEquals(7L, mapped.messageId) + } + } + + @Test + fun `the unit separator is text, not blank`() { + assertEquals( + listOf(MessageContent.System("\u001F")), + message(system("\u001F")).toChatMessage().content, + ) + } + + @Test + fun `only the blank content of a message is dropped`() { + assertEquals( + listOf(MessageContent.System("Sam left")), + message(system(" "), system("Sam left")).toChatMessage().content, + ) + } +}