From f9ca27cf875a67fc432f0a2ed492db648c78c1d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 15:58:04 -0400 Subject: [PATCH] fix(chat): keep the conversation title on one line The title and the group member-count line had no maxLines, so a long name wrapped inside the fixed 56dp bar and its second and third lines were clipped. Both now ellipsize on one line. The title keeps its weight(1f, fill = false), so it still gives up width to the mute bell. ConversationTitleBarTest moves to native graphics: under the legacy mode text has no real width, and a long title laid out on one line with or without the fix. --- .../internal/screens/components/ChatTopBar.kt | 5 +++ .../internal/ChatIdentityScreenshotTest.kt | 39 +++++++++++++++++ .../internal/ConversationTitleBarTest.kt | 43 ++++++++++++++++++- .../internal/GroupChromeScreenshotTest.kt | 39 +++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index e892c621e3..308ab07052 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -217,6 +218,8 @@ private fun ConversationTitleBar( text = state.subject?.title.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) // Beside the name, not at the bar's edge: what is muted is this chat, and @@ -237,6 +240,8 @@ private fun ConversationTitleBar( ), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index e5d2ba1188..8836f9e822 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -20,6 +20,8 @@ import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.core.CodeNavigator import io.mockk.mockk import org.junit.Rule @@ -135,6 +137,43 @@ class ChatIdentityScreenshotTest { capture("chat_top_bar_identity.png") } + /** A DM name too long for the bar, muted so the bell has to keep its place beside it. */ + @Test + fun rendersLongNameTopBar() { + val navigator = mockk(relaxed = true) + val longNamedTipUser = namedTipUser.copy( + profile = namedTipUser.profile.copy( + displayName = "Grace Brewster Murray Hopper, Rear Admiral", + ), + ) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(namedTipUser, longNamedTipUser).forEach { participant -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + subject = ChatSubject.TipUser(participant), + chatType = ChatType.TIP_DM, + viewerState = ViewerState(mute = MuteState.Forever, version = 1L), + ), + onBarHeightChange = {}, + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_top_bar_long_name.png") + } + @Test fun rendersProfileHeaderIdentityStates() { val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt index a1d4de7119..987b164833 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt @@ -2,11 +2,14 @@ package com.flipcash.app.messenger.internal import androidx.activity.ComponentActivity import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.click import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.onNodeWithText @@ -19,6 +22,8 @@ import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.models.ChatAction import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.core.CodeNavigator import io.mockk.mockk import io.mockk.verify @@ -27,6 +32,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import kotlin.test.assertEquals /** @@ -36,6 +42,8 @@ import kotlin.test.assertEquals * these hold in place: the row still announces itself as a button and still acts on a tap. */ @RunWith(RobolectricTestRunner::class) +// Native graphics so text measures at its real width; legacy mode lays a long title out on one line. +@GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") class ConversationTitleBarTest { @@ -53,12 +61,22 @@ class ConversationTitleBarTest { private val navigator = mockk(relaxed = true) - private fun show(onAction: (ChatAction) -> Unit = {}) { + private val longTitle = "The Extraordinarily Long Name Of A Group That Keeps Going" + + private fun show( + subject: ChatSubject = group, + viewerState: ViewerState? = null, + onAction: (ChatAction) -> Unit = {}, + ) { composeTestRule.setContent { FlipcashPreview { ChatTopBar( navigator = navigator, - state = ChatViewModel.State(chatType = ChatType.GROUP, subject = group), + state = ChatViewModel.State( + chatType = ChatType.GROUP, + subject = subject, + viewerState = viewerState, + ), onBarHeightChange = {}, chatActionHandler = onAction, dispatch = {}, @@ -122,4 +140,25 @@ class ConversationTitleBarTest { verify(exactly = 1) { navigator.pop() } } + + /** + * The bar is a fixed 56dp, so a title that wraps has its extra lines clipped by it. Muted, so the + * bell takes its share of the row too. + */ + @Test + fun `a long title stays on one line`() { + show( + subject = group.copy(groupTitle = longTitle), + viewerState = ViewerState(mute = MuteState.Forever, version = 1L), + ) + + assertEquals(1, composeTestRule.textLayout(longTitle).lineCount) + } + + private fun AndroidComposeTestRule<*, *>.textLayout(text: String): TextLayoutResult { + val results = mutableListOf() + onNodeWithText(text, useUnmergedTree = true).fetchSemanticsNode() + .config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) + return results.single() + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt index 5f9143c90c..2ec02e5225 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -20,6 +20,8 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.core.CodeNavigator import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint @@ -122,6 +124,39 @@ class GroupChromeScreenshotTest { capture("group_top_bar.png") } + /** A title too long for the bar, muted so the bell has to keep its place beside it. */ + @Test + fun rendersLongGroupTitleBar() { + val navigator = mockk(relaxed = true) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(memberedGroup, memberedGroup.copy(groupTitle = LONG_GROUP_TITLE)) + .forEach { group -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + subject = group, + chatType = ChatType.GROUP, + viewerState = ViewerState(mute = MuteState.Forever, version = 1L), + ), + onBarHeightChange = {}, + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_top_bar_long_title.png") + } + @Test fun rendersGroupInfoCard() { composeRule.mainClock.autoAdvance = false @@ -351,4 +386,8 @@ class GroupChromeScreenshotTest { if (right < left || bottom < top) return this return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) } + + private companion object { + const val LONG_GROUP_TITLE = "The Extraordinarily Long Name Of A Group That Keeps Going" + } }