Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<PagingData<ChatListItem.ContentBubble>> =
private val mappedMessages: Flow<PagingData<ChatListItem.TranscriptMessage>> =
combine(
messageStream,
pendingMutations,
Expand All @@ -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<MessageContent, ChatListItem.TranscriptMessage> { 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatListItem.ContentBubble>(dispatchers.Default) {
val presenter = object : PagingDataPresenter<ChatListItem.TranscriptMessage>(dispatchers.Default) {
override suspend fun presentPagingDataEvent(
event: PagingDataEvent<ChatListItem.ContentBubble>,
event: PagingDataEvent<ChatListItem.TranscriptMessage>,
) = Unit
}
pagingScope.launch { mappedMessages.collectLatest { presenter.collectFrom(it) } }
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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? {
Expand All @@ -81,11 +83,11 @@ private fun plainSeparatorBetween(
} else null
}

internal fun PagingData<ChatListItem.ContentBubble>.withSeparators(
internal fun PagingData<ChatListItem.TranscriptMessage>.withSeparators(
boundary: UnreadBoundary,
config: SeparatorConfig,
oldestEncryptedId: Long? = null,
): PagingData<ChatListItem> =
insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? ->
insertSeparators { newer: ChatListItem.TranscriptMessage?, older: ChatListItem.TranscriptMessage? ->
separatorBetween(newer, older, boundary, config, oldestEncryptedId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading
Loading