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)
+ }
}