From 88533394f8e6a1af77737520ea26ad7f4c4b214f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 15:31:30 -0400 Subject: [PATCH] feat(chat): label a chat with no messages "Nothing yet" A chat created but never spoken in left the row's subtitle blank, which reads as a preview that failed to load rather than as an empty chat. It now renders an italic "Nothing yet" where the preview goes. The placeholder is keyed on the chat having no last message, not on the preview coming back null. Media and system messages both preview as null today, so keying on the preview would call a chat full of photos empty; those rows keep the blank line. --- .../core/src/main/res/values/strings.xml | 1 + .../tipping/internal/components/TipChatRow.kt | 4 +++ .../internal/TipChatRowScreenshotTest.kt | 35 +++++++++++++++++++ .../flipcash/shared/chat/ui/ChatRowContent.kt | 21 +++++++++-- .../shared/chat/ui/ChatSummaryMapping.kt | 1 + .../shared/chat/ui/ConversationReference.kt | 8 +++++ .../shared/chat/ui/ChatSummaryPreviewTest.kt | 31 ++++++++++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 21b9ad578d..92cb2f7e00 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -953,6 +953,7 @@ %1$s: %2$s %1$s sent %2$s %1$s tipped %2$s + Nothing yet Is Typing… Edited diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 03bd7afd10..75244cf27d 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -69,7 +69,11 @@ internal fun TipChatRow( ChatRowSubtitle( isTyping = chat.isTyping, preview = chat.lastMessagePreview, + hasMessages = chat.hasMessages, fallback = { + // Blank rather than absent: a chat with a message the row cannot + // preview still occupies both lines, so it doesn't jump in height + // against its neighbours. SubtitleText("") } ) diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt index ed1c519a89..71734dcac7 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt @@ -79,6 +79,41 @@ class TipChatRowScreenshotTest { capture("tip_chat_row_identity.png") } + @Test + fun rendersEmptyChatPlaceholder() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + val rows = listOf( + // A group created but never spoken in: italic placeholder where the preview goes. + ConversationReference( + chatId = ChatId(byteArrayOf(4)), + title = "Bad Boys", + isGroup = true, + hasMessages = false, + lastActivity = at, + ), + // Its neighbour, for the weight the placeholder is read against. + ConversationReference( + chatId = ChatId(byteArrayOf(5)), + title = "Dinner Club", + isGroup = true, + lastMessagePreview = "Grace: on my way", + lastActivity = at, + ), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column(modifier = Modifier.width(360.dp).padding(vertical = 8.dp)) { + rows.forEach { TipChatRow(chat = it, onClick = {}) } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("tip_chat_row_empty.png") + } + private fun capture(name: String) { val root: View = composeRule.activity.findViewById(android.R.id.content) val width = root.width.takeIf { it > 0 } ?: 1080 diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index 66c2ca02d1..272525f1d9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import com.flipcash.core.R import com.getcode.theme.CodeTheme @@ -59,29 +60,43 @@ fun RowScope.ChatRowTrailing( } /** - * Subtitle line of a chat row: the typing indicator, else the message [preview], else the - * caller's [fallback] (e.g. a phone number or a "joined" line for a contact). + * Subtitle line of a chat row: the typing indicator, else the message [preview], else a placeholder + * when the chat has no messages at all ([hasMessages]), else the caller's [fallback] (e.g. a phone + * number or a "joined" line for a contact). + * + * The placeholder is keyed on [hasMessages] rather than on the absent [preview] because the two are + * not the same thing: a chat whose newest message is media or a system event previews as nothing + * but is not empty, and telling the viewer it is would be a lie. Rows that cannot tell the + * difference leave [hasMessages] alone and keep their own fallback. */ @Composable fun ChatRowSubtitle( isTyping: Boolean, preview: String?, + hasMessages: Boolean = true, fallback: @Composable () -> Unit = {}, ) { when { isTyping -> SubtitleText(stringResource(R.string.label_isTyping)) !preview.isNullOrEmpty() -> SubtitleText(preview) + !hasMessages -> SubtitleText( + text = stringResource(R.string.label_chat_preview_noMessages), + // Italic marks it as the row talking about itself rather than quoting a message — + // the same distinction the tombstone bubble draws. + fontStyle = FontStyle.Italic, + ) else -> fallback() } } /** Standard single-line secondary text used for chat/contact row subtitles. */ @Composable -fun SubtitleText(text: String) { +fun SubtitleText(text: String, fontStyle: FontStyle = FontStyle.Normal) { Text( text = text, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, + fontStyle = fontStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, ) 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 963abcf995..d708358e15 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 @@ -42,6 +42,7 @@ fun ChatSummary.toConversationReference( title = metadata.title, isGroup = isGroup, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), + hasMessages = metadata.lastMessage != null, lastActivity = metadata.lastActivity, unreadCount = unreadCount, viewerState = metadata.viewerState, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 99304e1c92..7414a4261c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -31,6 +31,14 @@ data class ConversationReference( /** Whether this row is a group. Decides where the name and the avatar's authority come from. */ val isGroup: Boolean = false, val lastMessagePreview: String? = null, + /** + * Whether the chat has any message at all. + * + * Separate from [lastMessagePreview] being null, which a chat with messages can also be — media + * and system events have no preview line yet. Only this says the chat was created and never + * spoken in, which is the one case a row can call empty. + */ + val hasMessages: Boolean = true, /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ val lastActivity: Instant? = null, val unreadCount: Int = 0, 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 18856b4262..70892c27d2 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 @@ -18,6 +18,7 @@ import io.mockk.every import io.mockk.mockk import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant @@ -226,4 +227,34 @@ class ChatSummaryPreviewTest { assertEquals("$25.00", groupReference(listOf(cash), senderId = stranger).lastMessagePreview) } + + @Test + fun `a group nobody has spoken in yet reports no messages`() { + val reference = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.GROUP, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ), + lastMessage = null, + lastActivity = sentAt, + title = "Bad Boys", + ), + unreadCount = 0, + ).toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + + assertFalse(reference.hasMessages) + assertNull(reference.lastMessagePreview) + } + + @Test + fun `a chat whose newest message has no preview still counts as having messages`() { + // Media previews as nothing today, which is not the same as an empty chat — the row must + // not label it "Nothing yet". + val reference = groupReference(listOf(MessageContent.Media(items = emptyList(), caption = null))) + + assertTrue(reference.hasMessages) + assertNull(reference.lastMessagePreview) + } }