Skip to content

Commit 055de89

Browse files
authored
fix(chat): render system messages in the transcript (#1769)
* fix(chat): drop blank system content and skip empty rows in chat previews * fix(chat): render system messages as notice rows in the transcript * fix(chat): style system notices with the caption type token * fix(chat): centre system notices with the info glyph beside the text
1 parent 42b976e commit 055de89

26 files changed

Lines changed: 545 additions & 34 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,7 +1193,7 @@ internal class ChatViewModel @AssistedInject constructor(
11931193
* separator config re-runs only [messages]' separator pass, not the per-message lookups here.
11941194
*/
11951195
@OptIn(ExperimentalCoroutinesApi::class)
1196-
private val mappedMessages: Flow<PagingData<ChatListItem.ContentBubble>> =
1196+
private val mappedMessages: Flow<PagingData<ChatListItem.TranscriptMessage>> =
11971197
combine(
11981198
messageStream,
11991199
pendingMutations,
@@ -1207,7 +1207,15 @@ internal class ChatViewModel @AssistedInject constructor(
12071207
}
12081208
pagingData.flatMap { stored ->
12091209
val message = stored.applying(mutations[stored.messageId])
1210-
message.content.flatMapIndexed { index, content ->
1210+
message.content.flatMapIndexed<MessageContent, ChatListItem.TranscriptMessage> { index, content ->
1211+
// A notice has no sender, receipt, capabilities or link card, so it skips all of
1212+
// that and becomes its own kind of row.
1213+
if (content is MessageContent.System) {
1214+
return@flatMapIndexed listOfNotNull(
1215+
systemMessageItem(message.messageId, index, message.timestamp, content),
1216+
)
1217+
}
1218+
12111219
val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) {
12121220
val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token
12131221
if (token != null) {
@@ -1356,7 +1364,9 @@ internal class ChatViewModel @AssistedInject constructor(
13561364
.let { pages ->
13571365
combine(pages, reactionOverlay) { data, overlay ->
13581366
if (overlay.isEmpty()) return@combine data
1359-
data.map { bubble ->
1367+
data.map { item ->
1368+
if (item !is ChatListItem.ContentBubble) return@map item
1369+
val bubble = item
13601370
val live = overlay[bubble.messageId] ?: return@map bubble
13611371
bubble.copy(
13621372
// Only the row the "Edited" marker also lands on carries the pill row, so a
@@ -1538,9 +1548,9 @@ internal class ChatViewModel @AssistedInject constructor(
15381548
* Nothing reads from this presenter, so it never asks for more than the first page.
15391549
*/
15401550
private fun initTranscriptPrefetch() {
1541-
val presenter = object : PagingDataPresenter<ChatListItem.ContentBubble>(dispatchers.Default) {
1551+
val presenter = object : PagingDataPresenter<ChatListItem.TranscriptMessage>(dispatchers.Default) {
15421552
override suspend fun presentPagingDataEvent(
1543-
event: PagingDataEvent<ChatListItem.ContentBubble>,
1553+
event: PagingDataEvent<ChatListItem.TranscriptMessage>,
15441554
) = Unit
15451555
}
15461556
pagingScope.launch { mappedMessages.collectLatest { presenter.collectFrom(it) } }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.flipcash.app.messenger.internal
2+
3+
import com.flipcash.services.models.chat.MessageContent
4+
import com.flipcash.shared.chat.models.ChatListItem
5+
import com.flipcash.shared.chat.models.displayText
6+
import kotlin.time.Instant
7+
8+
/**
9+
* The transcript row for one System content of a message, or `null` when it has no text to draw.
10+
* The null case is a notice stored before the mapper dropped blank ones; it produces no row at all.
11+
*/
12+
internal fun systemMessageItem(
13+
messageId: Long,
14+
contentIndex: Int,
15+
timestamp: Instant,
16+
content: MessageContent.System,
17+
): ChatListItem.SystemMessage? {
18+
val text = content.displayText() ?: return null
19+
return ChatListItem.SystemMessage(
20+
messageId = messageId,
21+
contentIndex = contentIndex,
22+
timestamp = timestamp,
23+
text = text,
24+
)
25+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose
3030
* the divider; otherwise a transcript would draw a second one under the same fixed key.
3131
*/
3232
internal fun unreadDividerBetween(
33-
newer: ChatListItem.ContentBubble,
34-
older: ChatListItem.ContentBubble?,
33+
newer: ChatListItem.TranscriptMessage,
34+
older: ChatListItem.TranscriptMessage?,
3535
boundary: UnreadBoundary,
3636
): Boolean {
3737
if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false
38+
// A notice is treated as already read: it is never counted, so it never opens the unread run.
39+
if (newer is ChatListItem.SystemMessage) return false
3840
if (older != null && older.messageId < 1) return false
3941
val olderId = older?.messageId ?: Long.MIN_VALUE
4042
return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId
@@ -47,8 +49,8 @@ internal fun unreadDividerBetween(
4749
* its last row takes the Encrypted marker, carrying whatever separator the gap would have had.
4850
*/
4951
internal fun separatorBetween(
50-
newer: ChatListItem.ContentBubble?,
51-
older: ChatListItem.ContentBubble?,
52+
newer: ChatListItem.TranscriptMessage?,
53+
older: ChatListItem.TranscriptMessage?,
5254
boundary: UnreadBoundary,
5355
config: SeparatorConfig,
5456
oldestEncryptedId: Long? = null,
@@ -64,8 +66,8 @@ internal fun separatorBetween(
6466
}
6567

6668
private fun plainSeparatorBetween(
67-
newer: ChatListItem.ContentBubble,
68-
older: ChatListItem.ContentBubble?,
69+
newer: ChatListItem.TranscriptMessage,
70+
older: ChatListItem.TranscriptMessage?,
6971
boundary: UnreadBoundary,
7072
config: SeparatorConfig,
7173
): ChatListItem? {
@@ -81,11 +83,11 @@ private fun plainSeparatorBetween(
8183
} else null
8284
}
8385

84-
internal fun PagingData<ChatListItem.ContentBubble>.withSeparators(
86+
internal fun PagingData<ChatListItem.TranscriptMessage>.withSeparators(
8587
boundary: UnreadBoundary,
8688
config: SeparatorConfig,
8789
oldestEncryptedId: Long? = null,
8890
): PagingData<ChatListItem> =
89-
insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? ->
91+
insertSeparators { newer: ChatListItem.TranscriptMessage?, older: ChatListItem.TranscriptMessage? ->
9092
separatorBetween(newer, older, boundary, config, oldestEncryptedId)
9193
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ internal fun MessageList(
649649
if (messages.itemCount > 0) {
650650
val oldest = messages.peek(messages.itemCount - 1)
651651
val oldestTimestamp = when (oldest) {
652-
is ChatListItem.ContentBubble -> oldest.timestamp
652+
is ChatListItem.TranscriptMessage -> oldest.timestamp
653653
is ChatListItem.DateSeparator -> null // already a separator
654654
is ChatListItem.UnreadDivider -> null // carries the oldest message's date
655655
is ChatListItem.EncryptedMarker -> null // carries it too, as `above`
@@ -680,7 +680,7 @@ internal fun MessageList(
680680
LaunchedEffect(listState, messages) {
681681
snapshotFlow {
682682
if (messages.itemCount == 0) null
683-
else (messages.peek(0) as? ChatListItem.ContentBubble)?.itemKey
683+
else (messages.peek(0) as? ChatListItem.TranscriptMessage)?.itemKey
684684
}
685685
.filterNotNull()
686686
.distinctUntilChanged()
@@ -700,7 +700,7 @@ internal fun MessageList(
700700
listState.firstVisibleItemScrollOffset == 0
701701
// Always scroll for own messages; only near-bottom for incoming
702702
val nearBottom = listState.firstVisibleItemIndex <= 5
703-
val newest = messages.peek(0) as? ChatListItem.ContentBubble
703+
val newest = messages.peek(0) as? ChatListItem.TranscriptMessage
704704
val editing = editingMessageId
705705
if (editing != null) {
706706
// A message arriving mid-edit still moves the transcript, or it would land

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ internal fun HandleMessageReads(
4343
var highestId = 0L
4444
for (info in visibleRange) {
4545
if (info.index !in 0 until count) continue
46-
val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue
47-
if (!bubble.isFromSelf && bubble.messageId > highestId) {
48-
highestId = bubble.messageId
46+
// A notice counts as read once seen, or the pointer would stall under a newest one.
47+
val message = messages.peek(info.index) as? ChatListItem.TranscriptMessage ?: continue
48+
if (!message.isFromSelf && message.messageId > highestId) {
49+
highestId = message.messageId
4950
}
5051
}
5152
if (highestId > 0L) highestId else null

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ internal fun MessageRow(
301301
)
302302
}
303303

304+
is ChatListItem.SystemMessage -> Box(insertionModifier) {
305+
SystemMessageRow(item.text)
306+
}
307+
304308
is ChatListItem.ContentBubble -> {
305309
val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
306310
// Bound to a local: `sender` is a property of another module's public API, so
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.flipcash.app.messenger.internal.screens.components
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.height
9+
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.foundation.layout.size
11+
import androidx.compose.foundation.layout.widthIn
12+
import androidx.compose.material.icons.Icons
13+
import androidx.compose.material.icons.outlined.Info
14+
import androidx.compose.material3.Icon
15+
import androidx.compose.material3.Text
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.ui.Alignment
18+
import androidx.compose.ui.Modifier
19+
import androidx.compose.ui.platform.LocalDensity
20+
import androidx.compose.ui.semantics.semantics
21+
import androidx.compose.ui.text.style.TextAlign
22+
import androidx.compose.ui.tooling.preview.Preview
23+
import androidx.compose.ui.tooling.preview.PreviewWrapper
24+
import androidx.compose.ui.unit.dp
25+
import com.flipcash.app.theme.FlipcashThemeWrapper
26+
import com.getcode.theme.CodeTheme
27+
28+
/**
29+
* A server notice, centred in the transcript (node 11721:879, option D, file Jxf2wD9QyfKaONbhHSdU0K):
30+
* an info glyph, then the text. No fill, no bubble and no sender label. The glyph is what tells it
31+
* apart from the date separator, which is also centred caption text.
32+
*
33+
* The text is drawn as it arrived: it wraps at [SYSTEM_TEXT_MAX_WIDTH], centred, with no `maxLines`
34+
* and no trimming. The glyph stays beside the first line.
35+
*/
36+
@Composable
37+
internal fun SystemMessageRow(text: String, modifier: Modifier = Modifier) {
38+
val lineHeight = with(LocalDensity.current) { CodeTheme.typography.caption.lineHeight.toDp() }
39+
40+
Row(
41+
modifier = modifier
42+
.fillMaxWidth()
43+
.padding(vertical = SYSTEM_ROW_VERTICAL_PADDING)
44+
.semantics(mergeDescendants = true) {},
45+
verticalAlignment = Alignment.Top,
46+
horizontalArrangement = Arrangement.spacedBy(SYSTEM_GLYPH_GAP, Alignment.CenterHorizontally),
47+
) {
48+
// One line tall, so the glyph centres on the first line however many the text wraps to.
49+
Box(modifier = Modifier.height(lineHeight), contentAlignment = Alignment.Center) {
50+
Icon(
51+
modifier = Modifier.size(SYSTEM_GLYPH_SIZE),
52+
imageVector = Icons.Outlined.Info,
53+
contentDescription = null,
54+
tint = CodeTheme.colors.textSecondary,
55+
)
56+
}
57+
Text(
58+
modifier = Modifier.widthIn(max = SYSTEM_TEXT_MAX_WIDTH),
59+
text = text,
60+
style = CodeTheme.typography.caption,
61+
color = CodeTheme.colors.textSecondary,
62+
textAlign = TextAlign.Center,
63+
)
64+
}
65+
}
66+
67+
private val SYSTEM_GLYPH_SIZE = 14.dp
68+
private val SYSTEM_GLYPH_GAP = 6.dp
69+
private val SYSTEM_TEXT_MAX_WIDTH = 250.dp
70+
private val SYSTEM_ROW_VERTICAL_PADDING = 6.dp
71+
72+
@Preview
73+
@PreviewWrapper(FlipcashThemeWrapper::class)
74+
@Composable
75+
private fun Preview_SystemMessageRow() {
76+
Column(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) {
77+
SystemMessageRow("Alex joined the group")
78+
SystemMessageRow("Alex added Sam, Jordan and Riley to the group, and changed the group name to Weekend plans")
79+
}
80+
}

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,13 @@ class RowGapTest {
9292
fun `a status line under a bubble ends the run with a normal gap`() {
9393
assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), bubble(alice), config, carriesReceipt = true))
9494
}
95+
96+
@Test
97+
fun `a notice sits a normal gap from either neighbour`() {
98+
val notice = ChatListItem.SystemMessage(messageId = 2L, contentIndex = 0, timestamp = noon, text = "Sam left")
99+
// Same sender and close in time would be Tight if the notice were a bubble from them.
100+
assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), notice, config))
101+
assertEquals(RowGap.Normal, rowGapBelow(notice, bubble(alice), config))
102+
assertEquals(RowGap.Normal, rowGapBelow(notice, notice, config))
103+
}
95104
}

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,18 @@ class SenderRunTest {
7676
assertTrue(startsSenderRun(unnamedAlice, older = unnamedBob))
7777
assertFalse(startsSenderRun(unnamedAlice, older = unnamedAlice))
7878
}
79+
80+
private val notice = ChatListItem.SystemMessage(
81+
messageId = 2L,
82+
contentIndex = 0,
83+
timestamp = Instant.fromEpochMilliseconds(1_000),
84+
text = "Sam left the group",
85+
)
86+
87+
@Test
88+
fun `a notice between two bubbles from one sender splits the run`() {
89+
// The bubble above the notice is Alice's, but the notice is not: the later bubble restarts
90+
// the run and wears the name and picture again.
91+
assertTrue(startsSenderRun(bubble(alice), older = notice))
92+
}
7993
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.flipcash.app.messenger.internal
2+
3+
import com.flipcash.services.models.chat.MessageContent
4+
import com.flipcash.services.models.chat.isSystemBlank
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFalse
8+
import kotlin.test.assertNotNull
9+
import kotlin.test.assertNull
10+
import kotlin.test.assertTrue
11+
import kotlin.time.Instant
12+
13+
/** The shared fixture with iOS: what a system notice becomes in the transcript, and what is blank. */
14+
class SystemMessageItemTest {
15+
16+
private val at = Instant.fromEpochMilliseconds(1_700_000_000_000)
17+
18+
private fun item(text: String, id: Long = 7, index: Int = 0) =
19+
systemMessageItem(id, index, at, MessageContent.System(text))
20+
21+
@Test
22+
fun `a notice becomes exactly one row with its own text`() {
23+
val row = assertNotNull(item("Alex joined the group"))
24+
assertEquals("Alex joined the group", row.text)
25+
assertEquals("sys-7", row.itemKey)
26+
assertEquals("system-message", row.itemContentType)
27+
assertEquals(7L, row.messageId)
28+
assertEquals(at, row.timestamp)
29+
assertFalse(row.isFromSelf)
30+
}
31+
32+
@Test
33+
fun `a second content of one message gets a suffixed key`() {
34+
assertEquals("sys-7-1", assertNotNull(item("Sam left", index = 1)).itemKey)
35+
assertEquals("sys-7", assertNotNull(item("Sam left", index = 0)).itemKey)
36+
}
37+
38+
@Test
39+
fun `blank text produces no row`() {
40+
listOf("", " ", "\n\t", "\u0085", "\u00A0", "\u3000").forEach { blank ->
41+
assertNull(item(blank), "expected no row for ${blank.map { it.code }}")
42+
}
43+
}
44+
45+
@Test
46+
fun `the unit separator is not blank`() {
47+
// Kotlin's isWhitespace() treats U+001C-001F as space and U+0085 as not; iOS is the reverse.
48+
assertFalse("\u001F".isSystemBlank())
49+
assertTrue("\u0085".isSystemBlank())
50+
assertNotNull(item("\u001F"))
51+
}
52+
53+
@Test
54+
fun `text that is not blank is kept unmodified`() {
55+
assertEquals(" Alex joined \n", assertNotNull(item(" Alex joined \n")).text)
56+
}
57+
58+
@Test
59+
fun `the shared whitespace set is blank and nothing else near it is`() {
60+
val whitespace = (0x09..0x0D) + 0x20 + 0x85 + 0xA0 + 0x1680 + (0x2000..0x200A) +
61+
0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000
62+
whitespace.forEach { assertTrue(it.toChar().toString().isSystemBlank(), "U+%04X".format(it)) }
63+
listOf(0x08, 0x0E, 0x1C, 0x1F, 0x21, 0x180E, 0x200B, 0x2060, 0xFEFF).forEach {
64+
assertFalse(it.toChar().toString().isSystemBlank(), "U+%04X".format(it))
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)