From 85a76dad93fb09d5f5be78bf45c38c90c1ef010d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 14:09:49 -0400 Subject: [PATCH] fix(chat): show a tip user's initials, not a silhouette, when they have no photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same person got two different avatars depending on the screen. The tips list and the blocklist call `ContactAvatar(image, displayName)`, which falls back to initials. The chat header, info card, profile sheet and activity feed call the `UserProfile` overload, which dropped the name on the floor and fell back to the person silhouette — so a tipper with no photo was "GH" in the tips list and an anonymous grey figure in the conversation they opened from it. Give that overload the same fallback, naming the person with `nameOrHandle` so a handle-only account gets the first letter of their handle rather than nothing. `InitialsText` already strips the `@`. A profile with neither a name nor a handle keeps the silhouette. The activity feed depends on that: its non-person rows pass `UserProfile.Empty` to get one. `ContactAvatarFallbackScreenshotTest` renders the three states side by side. --- .../shared/common-ui/build.gradle.kts | 1 + .../shared/common/ui/ContactAvatar.kt | 15 ++- .../ui/ContactAvatarFallbackScreenshotTest.kt | 105 ++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts index 87527e41bd..4dc9ca500d 100644 --- a/apps/flipcash/shared/common-ui/build.gradle.kts +++ b/apps/flipcash/shared/common-ui/build.gradle.kts @@ -17,5 +17,6 @@ dependencies { implementation(project(":apps:flipcash:shared:theme")) api(libs.compose.material.icons.extended) + testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) } 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 3251205439..c383aebee9 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 @@ -38,6 +38,8 @@ 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.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -103,15 +105,26 @@ fun ContactAvatar( } } +/** + * A person's avatar taken entirely from their server [UserProfile] — the chat header and info card, + * the activity feed. Falls back to the initials of their name-or-handle, matching the surfaces that + * pass the two separately ([ContactAvatar] with [MediaItem] + display name). + * + * A profile with neither a name nor a handle is not a person we can name, so it keeps the silhouette. + * The activity feed relies on that for its non-person rows, which pass [UserProfile.Empty]. + */ @Composable fun ContactAvatar( userProfile: UserProfile, modifier: Modifier = Modifier, ) { + val name = remember(userProfile) { nameOrHandle(userProfile.displayName, userProfile.handle) } ProfileAvatar( image = userProfile.profilePicture, modifier = modifier, - fallback = { UnknownContactAvatar(includeBorder = true) }, + fallback = { + if (name != null) InitialsText(name) else UnknownContactAvatar(includeBorder = true) + }, ) } diff --git a/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt new file mode 100644 index 0000000000..ade31e1e2d --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.shared.common.ui + +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.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.UserProfile +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 + +/** + * What a [UserProfile]-backed avatar falls back to when the person has no picture: initials for + * anyone we can name, the silhouette only for a profile that isn't a person. + * + * Not an assertion test — it writes to `build/screenshots/` so the three states can be compared by + * eye. Same mechanics as `ChatIdentityScreenshotTest`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h400dp-xhdpi") +class ContactAvatarFallbackScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersProfileAvatarFallbacks() { + val named = UserProfile.Empty.copy(displayName = "Grace Hopper", username = "grace_hopper") + // No display name — named by handle, so the initial comes from the handle with its `@` + // stripped. This is the case that used to render a silhouette. + val handleOnly = UserProfile.Empty.copy(username = "sally_streamer") + // Not a person: the activity feed passes this for its generic rows. + val anonymous = UserProfile.Empty + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(named, handleOnly, anonymous).forEach { profile -> + ContactAvatar( + userProfile = profile, + modifier = Modifier.requiredSize(64.dp).clip(CircleShape), + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("contact_avatar_fallbacks.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})") + } + + /** 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) + } +}