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