diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt index d76833a358..9ca5f957bd 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/blocklist/BlockedUserProfile.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.blocklist import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.nameOrHandle import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -11,12 +12,22 @@ import kotlin.time.Instant * * @param userId The blocked user * @param displayName Resolved display name, or empty if the profile could not be resolved + * @param handle Resolved public `@handle`, or null if unclaimed/unresolved * @param profilePicture Resolved avatar, or null if unset/unresolved * @param blockedAt When the user was blocked */ data class BlockedUserProfile( val userId: ID, val displayName: String, + val handle: String?, val profilePicture: MediaItem?, val blockedAt: Instant, -) +) { + /** + * What to call this person: [displayName] when they have one, [handle] when they don't. + * + * Blocking is reachable from a tip DM, so a blocked account need never have had a name — the + * same rule the chat surfaces use ([com.flipcash.app.core.chat.ChatParticipant.name]). + */ + val name: String? get() = nameOrHandle(displayName, handle) +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt index 1574943a9f..930ebb662f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle import com.getcode.opencode.model.core.ID import kotlinx.parcelize.Parcelize @@ -31,6 +32,15 @@ sealed interface ChatParticipant: Parcelable { */ val handle: String? + /** + * What to call this person — the one rule every surface that names them uses. + * + * [displayName] when they have one, [handle] when they don't. Null only when they have + * neither, which for a [TipUser] means a profile the server sent us nothing identifying for, + * and for a [Contact] means a device contact with an empty name. + */ + val name: String? get() = nameOrHandle(displayName, handle) + data class Contact(val contact: DeviceContact) : ChatParticipant { override val displayName: String get() = contact.displayName override val handle: String? get() = null diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 516415afc9..7047aac358 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -28,4 +28,5 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) + testImplementation(libs.robolectric) } 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 4543ddc97a..b3588c83de 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 @@ -83,7 +83,9 @@ internal fun ChatTopBar( Text( modifier = Modifier.weight(1f), - text = state.participant?.displayName.orEmpty(), + // Name-or-handle: the bar is one line (node 9443:9094), and the handle is + // the only identity a name-less tip DM counterparty has. + text = state.participant?.name.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 977b76e14d..3a5285b3df 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -81,7 +81,7 @@ internal fun ContactInfoContainer( ) { Text( modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier, - text = participant?.displayName.orEmpty(), + text = participant?.name.orEmpty(), autoSize = TextAutoSize.StepBased( minFontSize = CodeTheme.typography.textSmall.fontSize, maxFontSize = CodeTheme.typography.textLarge.fontSize, @@ -103,8 +103,9 @@ internal fun ContactInfoContainer( // The line under the name says how this person is addressed: a tip DM by their public // handle (node 9443:8928), a contact DM by the number the chat is keyed on. Never both — - // only one of the two identity sources backs any given conversation. - val handle = participant?.handle + // only one of the two identity sources backs any given conversation. Dropped when the name + // above already *is* the handle, so a name-less account doesn't show it twice. + val handle = participant?.handle?.takeIf { it != participant.name } if (handle != null) { Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), @@ -277,6 +278,13 @@ private fun Preview_AllStates() { ), ) + // A tip DM's counterparty who never set a name: the handle is their whole identity, so it + // takes the name line and the line beneath it is dropped. + val handleOnlyUser = ChatParticipant.TipUser( + userId = listOf(2.toByte()), + profile = UserProfile.Empty.copy(username = "sally_streamer"), + ) + // Fixed width so every state renders at the same size regardless of name/number length. val cardWidth = Modifier.width(300.dp) Column( @@ -286,6 +294,7 @@ private fun Preview_AllStates() { ContactInfoContainer(participant = knownContact, modifier = cardWidth) ContactInfoContainer(participant = unknownContact, modifier = cardWidth) ContactInfoContainer(participant = tipUser, modifier = cardWidth) + ContactInfoContainer(participant = handleOnlyUser, modifier = cardWidth) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 350eeeda3c..c433de17f9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -79,8 +80,9 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { } } +@VisibleForTesting @Composable -private fun ProfileHeader( +internal fun ProfileHeader( participant: ChatParticipant?, joinDate: Instant?, modifier: Modifier = Modifier, @@ -97,12 +99,25 @@ private fun ProfileHeader( ) Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), - text = participant?.displayName.orEmpty(), + text = participant?.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + // The handle sits under the name, the same shape as the info card's identity line + // (node 9443:8928). Left out when the line above is already the handle, so a name-less + // account doesn't read it twice. + participant?.handle?.takeIf { it != participant.name }?.let { handle -> + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = handle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } joinDate?.let { instant -> Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 6377c1d3c2..67bcc60626 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -93,7 +93,11 @@ internal class ChatProfileViewModel @Inject constructor( .filterIsInstance() .onEach { participant -> BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_blockUser, participant.displayName), + // "Block ?" is what this read for an account with no display name. + title = resources.getString( + R.string.prompt_title_blockUser, + participant.name.orEmpty(), + ), message = resources.getString(R.string.prompt_description_blockUser), actions = listOf( BottomBarAction( 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 new file mode 100644 index 0000000000..562ff695ab --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -0,0 +1,191 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.messenger.internal.screens.components.ContactInfoContainer +import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.getcode.navigation.core.CodeNavigator +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the DM info card in each identity state to a PNG so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + * + * Same mechanics as `TokenCardWatermarkScreenshotTest`: pause the clock, pump a fixed number of + * frames, and draw the Android view directly, so a composable that keeps scheduling frames can't + * hang `captureToImage()`'s implicit `waitForIdle`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi") +class ChatIdentityScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + // A saved device contact: name + number, handle line never applies. + private val knownContact = ChatParticipant.Contact( + DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + ) + + // A tip DM counterparty with both: name on top, handle underneath. + private val namedTipUser = ChatParticipant.TipUser( + userId = listOf(1.toByte()), + profile = UserProfile.Empty.copy( + displayName = "Grace Hopper", + username = "grace_hopper", + ), + ) + + // A tip DM counterparty who never set a name — the case this change fixes. Before, every + // surface below rendered them as an empty string. + private val handleOnlyTipUser = ChatParticipant.TipUser( + userId = listOf(2.toByte()), + profile = UserProfile.Empty.copy(username = "sally_streamer"), + ) + + @Test + fun rendersInfoCardIdentityStates() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val cardWidth = Modifier.width(300.dp) + ContactInfoContainer(participant = knownContact, modifier = cardWidth) + ContactInfoContainer(participant = namedTipUser, modifier = cardWidth) + ContactInfoContainer(participant = handleOnlyTipUser, modifier = cardWidth) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_info_card_identity.png") + } + + @Test + fun rendersTopBarIdentityStates() { + 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(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + participant = participant, + chatType = if (participant is ChatParticipant.TipUser) { + ChatType.TIP_DM + } else { + ChatType.CONTACT_DM + }, + ), + chatActionHandler = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_top_bar_identity.png") + } + + @Test + fun rendersProfileHeaderIdentityStates() { + val joinDate = Instant.fromEpochMilliseconds(1_700_000_000_000) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + listOf(knownContact, namedTipUser, handleOnlyTipUser).forEach { participant -> + ProfileHeader( + participant = participant, + joinDate = joinDate, + modifier = Modifier.width(300.dp), + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("chat_profile_header_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** + * The content view is the full device, but the previews wrap their content — crop away the + * untouched (fully transparent) margin so the PNG is just what was composed. + */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt index 10e5a2c06c..92709fbb80 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenContent.kt @@ -112,7 +112,7 @@ private fun BlockedUserRow( ) { ContactAvatar( image = user.profilePicture, - displayName = user.displayName, + displayName = user.name.orEmpty(), // Blocked users are shown obscured, per the design. blurred = true, modifier = Modifier @@ -121,7 +121,8 @@ private fun BlockedUserRow( ) Text( modifier = Modifier.weight(1f), - text = user.displayName, + // One line, so the handle stands in for a missing name rather than sitting under it. + text = user.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, maxLines = 1, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt index 93b2580f83..f75d9e8ab5 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistViewModel.kt @@ -60,7 +60,7 @@ internal class BlocklistViewModel @Inject constructor( BottomBarManager.showMessage( title = resources.getString( R.string.prompt_title_unblockUser, - event.user.displayName, + event.user.name.orEmpty(), ), message = resources.getString(R.string.prompt_description_unblockUser), actions = listOf( diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt new file mode 100644 index 0000000000..d41d749243 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/blocklist/BlocklistScreenshotTest.kt @@ -0,0 +1,111 @@ +package com.flipcash.app.myaccount.internal.blocklist + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.core.blocklist.BlockedUserProfile +import com.flipcash.app.theme.FlipcashPreview +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the blocklist in each identity state to a PNG, so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class BlocklistScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersRowIdentityStates() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + val users = listOf( + BlockedUserProfile( + userId = listOf(1.toByte()), + displayName = "Grace Hopper", + handle = "@grace_hopper", + profilePicture = null, + blockedAt = at, + ), + // Blocking is reachable from a tip DM, so a blocked account need never have had a name. + BlockedUserProfile( + userId = listOf(2.toByte()), + displayName = "", + handle = "@sally_streamer", + profilePicture = null, + blockedAt = at, + ), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + val items = flowOf(PagingData.from(users)).collectAsLazyPagingItems() + FlipcashPreview(showBackground = true) { + Box(modifier = Modifier.width(360.dp).height(200.dp)) { + BlocklistScreenContent( + blocked = items, + unblocking = emptyMap(), + onUnblock = {}, + ) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("blocklist_row_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index 8fbc71b423..a44d473254 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) 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 ba7fcebf7c..6aab7b47e2 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 @@ -27,7 +27,7 @@ internal fun TipChatRow( avatar = { ContactAvatar( image = chat.image, - displayName = chat.displayName.orEmpty(), + displayName = chat.name.orEmpty(), modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), @@ -36,7 +36,9 @@ internal fun TipChatRow( title = { Text( modifier = Modifier.weight(1f), - text = chat.displayName.orEmpty(), + // Name, or the `@handle` when there isn't one — the row's single line of identity + // (node 9442:103645 has the preview under it, so there is nowhere else to put it). + text = chat.name.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) 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 new file mode 100644 index 0000000000..ed1c519a89 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt @@ -0,0 +1,115 @@ +package com.flipcash.app.tipping.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders the tips list row in each identity state to a PNG, so the name-or-handle rule can be + * eyeballed without an emulator. Not an assertion test — it writes to `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class TipChatRowScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersRowIdentityStates() { + val at = Instant.fromEpochMilliseconds(1_700_000_000_000) + val rows = listOf( + // Name and handle both present: the row is one line, so only the name shows. + ConversationReference( + chatId = ChatId(byteArrayOf(1)), + displayName = "Grace Hopper", + handle = "@grace_hopper", + lastMessagePreview = "Sent you $5.00 in Dollars", + lastActivity = at, + ), + // No name — the handle stands in for it, on the same line. + ConversationReference( + chatId = ChatId(byteArrayOf(2)), + displayName = null, + handle = "@sally_streamer", + lastMessagePreview = "Sent you $1.00 in Dollars", + lastActivity = at, + unreadCount = 2, + ), + // Neither, which is what every name-less tipper used to render as. + ConversationReference( + chatId = ChatId(byteArrayOf(3)), + displayName = null, + handle = null, + lastMessagePreview = "Sent you $0.25 in Dollars", + 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_identity.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} 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 28833324a0..46c1ec822d 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 @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token @@ -17,6 +18,9 @@ import com.getcode.util.resources.ResourceHelper * is taken from the chat member that isn't [selfId] — used directly by rows with no * separate contact (e.g. tip DMs); the send flow ignores those in favour of its matched * device contact. + * + * The handle rides along with the name because a tip DM counterparty need not have a display + * name; [ConversationReference.name] is what rows should render. */ fun ChatSummary.toConversationReference( selfId: ID?, @@ -27,6 +31,7 @@ fun ChatSummary.toConversationReference( return ConversationReference( chatId = metadata.chatId, displayName = other?.userProfile?.displayName, + handle = other?.userProfile?.handle, image = other?.userProfile?.profilePicture, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, 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 928717b37e..670c74a2ac 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 @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.nameOrHandle import kotlin.time.Instant /** Presentation state derived from an existing DM with a contact. */ @@ -9,6 +10,11 @@ data class ConversationReference( val chatId: ChatId, /** Counterparty display name — used when the row has no separate contact (e.g. tip DMs). */ val displayName: String? = null, + /** + * Counterparty public `@handle`, or null when they haven't claimed one. Only tip DMs carry + * one — a contact DM's counterparty is addressed by phone number. + */ + val handle: String? = null, /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ val image: MediaItem? = null, val lastMessagePreview: String? = null, @@ -16,4 +22,12 @@ data class ConversationReference( val lastActivity: Instant? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, -) \ No newline at end of file +) { + /** + * What to call the counterparty: [displayName] when they have one, [handle] when they don't. + * + * The same rule [com.flipcash.app.core.chat.ChatParticipant.name] applies in the messenger, so a + * tip DM reads the same in the list as it does once opened. + */ + val name: String? get() = nameOrHandle(displayName, handle) +} \ No newline at end of file diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 8afa2d8132..3251205439 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -35,6 +35,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.request.placeholder import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.HandlePrefix import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem import com.getcode.theme.CodeTheme @@ -272,7 +273,10 @@ private fun UnknownContactAvatar( @Composable private fun BoxWithConstraintsScope.InitialsText(displayName: String) { val initials = remember(displayName) { - displayName.split(" ") + // Callers pass a name-or-handle, so strip the `@` first — otherwise every handle-only + // account gets the same "@" avatar instead of its own first letter. + displayName.removePrefix(HandlePrefix) + .split(" ") .take(2) .mapNotNull { it.firstOrNull()?.uppercaseChar() } .joinToString("") diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json new file mode 100644 index 0000000000..e75f0ba6be --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/31.json @@ -0,0 +1,768 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "7d43a42edcd48f8d5e52fd60b25b32d1", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d43a42edcd48f8d5e52fd60b25b32d1')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 49a51a7772..fc4276eac8 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -91,8 +91,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 27, to = 28), // tokens.market_cap_metrics (nullable) AutoMigration(from = 28, to = 29, spec = FlipcashDatabase.Migration28To29::class), AutoMigration(from = 29, to = 30), // chat_metadata.analytics_counted_through + AutoMigration(from = 30, to = 31), // user_profiles.username (nullable) ], - version = 30, + version = 31, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index f6188de758..5af3e3dff3 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -78,6 +78,7 @@ class ChatTypeConverters { VerifiableContactMethod(address, verified = true) }, profilePicture = compat.profilePicture, + username = compat.username, ) }.getOrNull() } @@ -194,6 +195,7 @@ data class UserProfileSerialized( val phoneNumber: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, val profilePicture: MediaItem? = null, + val username: String? = null, ) /** @@ -211,6 +213,7 @@ private data class UserProfileSerializedCompat( val verifiedPhoneNumber: String? = null, val verifiedEmailAddress: String? = null, val profilePicture: MediaItem? = null, + val username: String? = null, ) @Serializable diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt index 471a0f8cfa..487be14087 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/UserProfileDao.kt @@ -35,7 +35,7 @@ interface UserProfileDao { INSERT OR REPLACE INTO user_profiles ( user_id_hex, display_name, phone_value, phone_verified, email_value, email_verified, social_accounts_json, - profile_picture_json, pending_migration_json + profile_picture_json, username, pending_migration_json ) VALUES ( :userIdHex, :displayName, @@ -45,6 +45,7 @@ interface UserProfileDao { (SELECT email_verified FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT social_accounts_json FROM user_profiles WHERE user_id_hex = :userIdHex), COALESCE(:profilePicture, (SELECT profile_picture_json FROM user_profiles WHERE user_id_hex = :userIdHex)), + (SELECT username FROM user_profiles WHERE user_id_hex = :userIdHex), (SELECT pending_migration_json FROM user_profiles WHERE user_id_hex = :userIdHex) ) """ diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt index ab28cd621f..4cbab76d79 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntity.kt @@ -33,5 +33,8 @@ data class UserProfileEntity( @ColumnInfo(name = "email_verified") val emailVerified: Boolean?, @ColumnInfo(name = "social_accounts_json") val socialAccounts: List?, @ColumnInfo(name = "profile_picture_json") val profilePicture: MediaItem?, + // The public `@handle`, bare (no `@` — that is presentation, added by + // [com.flipcash.services.models.handle]). Null when the user hasn't claimed one. + @ColumnInfo(name = "username") val username: String? = null, @ColumnInfo(name = "pending_migration_json") val pendingMigrationJson: String? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt index f759f4c788..0a29cf9c80 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/UserProfileEntityMapping.kt @@ -24,6 +24,7 @@ fun UserProfileEntity.toSerialized(): UserProfileSerialized { phoneNumber = phoneValue?.let { VerifiableContactMethod(it, phoneVerified ?: false) }, email = emailValue?.let { VerifiableContactMethod(it, emailVerified ?: false) }, profilePicture = profilePicture, + username = username, ) } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt index e4285b4664..7e876c8409 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/UserProfileMigrationTest.kt @@ -170,6 +170,7 @@ class UserProfileDaoTest { emailValue = "a@b.com", emailVerified = false, socialAccounts = emptyList(), profilePicture = MediaItem(renditions = emptyList()), + username = "alice", ) @Test @@ -188,6 +189,17 @@ class UserProfileDaoTest { } } + @Test + fun `partial name+avatar write preserves the cached username`() = runBlocking { + val dao = db.userProfileDao() + dao.upsertFull(listOf(fullProfile("u1"))) + + // The blocklist has no username to write; the handle must survive its sync. + dao.upsertNameAndAvatar(userIdHex = "u1", displayName = "Alice (blocked)", profilePicture = null) + + assertEquals("alice", dao.getByUserId("u1")?.username) + } + @Test fun `chat member relation joins the shared normalized profile`() = runBlocking { db.userProfileDao().upsertFull(listOf(fullProfile("u1"))) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt index b0f585939e..7100900e29 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/UserProfileDomainMappers.kt @@ -16,6 +16,7 @@ fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( phoneNumber = phoneNumber, email = email, profilePicture = profilePicture, + username = username, ) fun SocialAccountSerialized.toDomain(): SocialAccount = when (this) { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt index 2bfed1fb63..bc196a23a6 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/blocklist/BlockedUserEntityToProfileMapper.kt @@ -3,6 +3,7 @@ package com.flipcash.app.persistence.sources.mapper.blocklist import com.flipcash.app.core.blocklist.BlockedUserProfile import com.flipcash.app.persistence.entities.BlockedUserWithProfile import com.flipcash.app.persistence.entities.toSerialized +import com.flipcash.services.models.asHandle import com.getcode.opencode.model.core.ID import com.getcode.opencode.mapper.Mapper import javax.inject.Inject @@ -17,6 +18,7 @@ class BlockedUserEntityToProfileMapper @Inject constructor() : return BlockedUserProfile( userId = from.blocked.userIdHex.hexToId(), displayName = profile?.displayName.orEmpty(), + handle = profile?.username?.takeIf { it.isNotBlank() }?.asHandle(), profilePicture = profile?.profilePicture, blockedAt = Instant.fromEpochMilliseconds(from.blocked.blockedAtEpochMs), ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index c9e6e6f24a..3f92ad34e5 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -175,6 +175,7 @@ class ChatEntityMapper @Inject constructor() { emailVerified = profile.email?.verified, socialAccounts = profile.socialAccounts.map { it.toSerialized() }, profilePicture = profile.profilePicture, + username = profile.username, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 9a31dc7d71..028590e92c 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -1,7 +1,12 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.handle import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import org.junit.Assert.assertEquals @@ -57,6 +62,36 @@ class ChatEntityMapperTest { assertEquals(0L, metadata.latestEventSequence) } + /** + * The handle a tip DM falls back to when its counterparty never set a name only reaches the UI + * through this cache — both the feed and the open conversation read members from Room, not from + * the wire response. Dropping the username here made [UserProfile.handle] null everywhere. + */ + @Test + fun `a member's username survives the round trip through the profile row`() { + val member = ChatMember( + userId = listOf(0xAB.toByte()), + userProfile = UserProfile.Empty.copy(displayName = "", username = "sally_streamer"), + pointers = emptyList(), + ) + + val profileRow = mapper.toProfileEntity(member) + assertEquals("sally_streamer", profileRow.username) + + val readBack = mapper.toMember( + ChatMemberWithProfile( + member = ChatMemberEntity( + chatIdHex = CHAT_HEX, + userIdHex = profileRow.userIdHex, + pointersJson = null, + ), + profile = profileRow, + ) + ) + + assertEquals("@sally_streamer", readBack.userProfile.handle) + } + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt index e39d54a624..e45000ad21 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt @@ -48,3 +48,19 @@ val UserProfile.handle: String? /** The linked X account's handle, shown the same way a Flipcash one is. */ val SocialAccount.TwitterX.handle: String get() = username.asHandle() + +/** + * How a person is named: their display name when they have one, their `@handle` when they don't. + * + * A `TIP_DM` counterparty is only ever identified by their server profile, and a display name is not + * required to hold one — `FeedSyncDelegate.feed` keeps name-less tip DMs in the feed on purpose, + * where a `CONTACT_DM` without an identity is dropped. Every surface that named such a person by + * `displayName` alone rendered an empty string. The handle is public and stable, so it is the + * identity to fall back to, and for some of these accounts it is the only one there is. + * + * Blank counts as absent on both sides, so a profile carrying `""` reads the same as one carrying + * nothing. Returns null only when the person has neither — a contact DM's counterparty has no handle + * by design, so for those this is just the display name. + */ +fun nameOrHandle(displayName: String?, handle: String?): String? = + displayName?.takeIf { it.isNotBlank() } ?: handle?.takeIf { it.isNotBlank() } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt index 979788d578..87c2b4c77c 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt @@ -90,4 +90,28 @@ class HandleTest { fun `a claimed username reads as a handle`() { assertEquals("@sally_streamer", UserProfile.Empty.copy(username = "sally_streamer").handle) } + + @Test + fun `a display name wins over a handle`() { + assertEquals("Grace Hopper", nameOrHandle("Grace Hopper", "@grace_hopper")) + } + + @Test + fun `the handle stands in when there is no display name`() { + assertEquals("@sally_streamer", nameOrHandle(null, "@sally_streamer")) + assertEquals("@sally_streamer", nameOrHandle("", "@sally_streamer")) + assertEquals("@sally_streamer", nameOrHandle(" ", "@sally_streamer")) + } + + @Test + fun `neither leaves nothing to render`() { + assertNull(nameOrHandle(null, null)) + assertNull(nameOrHandle("", "")) + assertNull(nameOrHandle(" ", " ")) + } + + @Test + fun `a contact DM keeps its name with no handle to fall back to`() { + assertEquals("Ada Lovelace", nameOrHandle("Ada Lovelace", null)) + } }