From b13a2f57c3e0aa8ccd74a3ee7af4ca3acd0b5bea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 15:29:34 -0400 Subject: [PATCH 1/6] feat(chat): show typists' avatars in a group's typing indicator ChatBottomBar called TypingIndicator without userImages, so its avatar row never drew. The ViewModel now derives one entry per typist, oldest first, from the sender profiles the transcript already resolves, and the bottom bar passes them in. DMs and tip chats get an empty list and keep the dots on their own. An entry is a picture URL from MediaUrlResolver, or the typist's user id when there is no picture or no profile yet, which UserAvatar draws as the Person fallback. --- .../app/messenger/internal/ChatViewModel.kt | 35 ++++++ .../app/messenger/internal/TypingAvatars.kt | 46 ++++++++ .../screens/components/ChatBottomBar.kt | 1 + .../messenger/internal/TypingAvatarsTest.kt | 102 ++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 58d0b1fe7f..0fce087762 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -20,6 +20,7 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.tokens.brandedName @@ -39,6 +40,7 @@ import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -120,6 +122,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -163,6 +166,7 @@ internal class ChatViewModel @Inject constructor( private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, private val chatDraftStore: ChatDraftStore, + private val mediaUrlResolver: MediaUrlResolver, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -199,6 +203,8 @@ internal class ChatViewModel @Inject constructor( val chatType: ChatType = ChatType.UNKNOWN, val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), + /** What the typing indicator draws ahead of its dots. See [typingAvatars]. */ + val typingAvatars: List = emptyList(), val resolveState: ResolveState = ResolveState.Pending, val sendProgress: LoadingSuccessState = LoadingSuccessState(), val isSelfTyping: Boolean = false, @@ -467,6 +473,7 @@ internal class ChatViewModel @Inject constructor( data object OnStopMessageInput: Event data object OnMessageInputConsumed: Event data class TypistsUpdated(val typists: Set) : Event + data class TypingAvatarsUpdated(val avatars: List) : Event data object ResolveCompleted : Event data object ResolveFailed : Event @@ -1445,6 +1452,33 @@ internal class ChatViewModel @Inject constructor( .onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) } .launchIn(viewModelScope) + // Faces for the typing indicator, from the same profiles the transcript attributes + // messages with. A typist the roster doesn't cover is asked for, like an unknown sender, + // and draws the fallback until their profile lands. + combine( + stateFlow.map { it.typists }.distinctUntilChanged(), + stateFlow.map { it.chatType }.distinctUntilChanged(), + senderProfiles, + ::Triple, + ) + .onEach { (typists, _, profiles) -> + profiles ?: return@onEach + typists.filter { it.userId.hexEncodedString() !in profiles } + .forEach { chatCoordinator.requestSenderProfile(it.userId) } + } + .mapLatest { (typists, chatType, profiles) -> + typingAvatars(typists, chatType, profiles.orEmpty()) { userId, picture -> + mediaUrlResolver.urlForSize( + media = picture, + targetLongestSidePx = TypingAvatarPx, + access = BlobAccessContext.profile(userId), + ) + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.TypingAvatarsUpdated(it)) } + .launchIn(viewModelScope) + // A DM opens its composer once a payment has been exchanged. A group has no such // exchange to wait for: its own rules say who may post, and [GroupAccess] has already // applied them — a group that reaches the composer at all is [GroupAccess.Membered], and @@ -2225,6 +2259,7 @@ internal class ChatViewModel @Inject constructor( Event.OnStopMessageInput -> { state -> state } Event.OnMessageInputConsumed -> { state -> state.copy(messageInputRequested = false) } is Event.TypistsUpdated -> { state -> state.copy(typists = event.typists) } + is Event.TypingAvatarsUpdated -> { state -> state.copy(typingAvatars = event.avatars) } Event.ResolveCompleted -> { state -> state.copy(resolveState = ResolveState.Resolved) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt new file mode 100644 index 0000000000..8ba0197882 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt @@ -0,0 +1,46 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.chat.ActiveTypist +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString + +/** + * What `TypingIndicator` draws ahead of its dots: one entry per typist, oldest first, so the + * indicator's `takeLast` keeps the people who started most recently. + * + * Group chats only. A DM or tip chat already names its one counterparty in the title bar, so it + * keeps the dots on their own and this is empty. + * + * An entry is the typist's picture URL from [pictureUrl], or their user id when there is no URL to + * give — no picture, or a profile that hasn't resolved yet. `UserAvatar` draws its Person fallback + * for an id, and the id is stable per user, which the indicator's `key = hashCode()` relies on to + * keep each avatar in place as others join and leave. + * + * [pictureUrl] is handed the id alongside the picture because the id is what authorizes re-minting + * an expired download URL for someone else's avatar. + */ +internal suspend fun typingAvatars( + typists: Set, + chatType: ChatType, + profiles: Map, + pictureUrl: suspend (userId: ID, picture: MediaItem) -> String?, +): List { + if (chatType != ChatType.GROUP) return emptyList() + + return typists + .sortedWith(compareBy({ it.since }, { it.userId.hexEncodedString() })) + .map { typist -> + profiles[typist.userId.hexEncodedString()]?.profilePicture + ?.let { pictureUrl(typist.userId, it) } + ?: typist.userId + } +} + +/** + * The rendition size asked for. The indicator's avatars are `staticGrid.x8` (40dp), which is 160px + * at xxxhdpi and smaller below it, and 160 is one of the thumbnail sizes the server derives. + */ +internal const val TypingAvatarPx = 160 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index a976a9c66f..63ab2f5236 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -132,6 +132,7 @@ internal fun UserControlBottomBar( TypingIndicator( modifier = Modifier .hazeBlur(HazeInput.Sources(hazeState), material), + userImages = state.typingAvatars, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt new file mode 100644 index 0000000000..be617490c2 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt @@ -0,0 +1,102 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.chat.ActiveTypist +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The faces the typing indicator draws. Pinned: a group lists its typists oldest first so the + * indicator's `takeLast` keeps the newest, a DM or tip chat lists nobody, and a typist who stops + * drops out. + */ +class TypingAvatarsTest { + + private val ada: ID = List(16) { 1 } + private val grace: ID = List(16) { 2 } + private val linus: ID = List(16) { 3 } + + private val adaPicture = MediaItem(renditions = emptyList()) + + private val profiles = mapOf( + ada.hexEncodedString() to profile("Ada", picture = adaPicture), + grace.hexEncodedString() to profile("Grace", picture = null), + ) + + private fun profile(name: String, picture: MediaItem?) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + profilePicture = picture, + ) + + private fun typing(userId: ID, atMillis: Long) = + ActiveTypist(userId, Instant.fromEpochMilliseconds(atMillis)) + + private val pictureUrl: suspend (ID, MediaItem) -> String? = { userId, _ -> + "https://cdn/${userId.hexEncodedString()}" + } + + @Test + fun `a group lists its typists oldest first`() = runTest { + val avatars = typingAvatars( + // Out of order on purpose: a set carries no order, so `since` has to supply it. + typists = setOf(typing(linus, 300), typing(ada, 100), typing(grace, 200)), + chatType = ChatType.GROUP, + profiles = profiles, + pictureUrl = pictureUrl, + ) + + // Ada has a picture; Grace has a profile but no picture; Linus has no profile yet. The + // last two fall back to their ids, which is what draws the Person icon. + assertEquals(listOf("https://cdn/${ada.hexEncodedString()}", grace, linus), avatars) + } + + @Test + fun `a DM with a typist lists nobody`() = runTest { + val typists = setOf(typing(ada, 100)) + + assertEquals( + emptyList(), + typingAvatars(typists, ChatType.CONTACT_DM, profiles, pictureUrl), + ) + assertEquals( + emptyList(), + typingAvatars(typists, ChatType.TIP_DM, profiles, pictureUrl), + ) + } + + @Test + fun `a typist who stops typing drops out`() = runTest { + val both = setOf(typing(ada, 100), typing(grace, 200)) + assertEquals( + listOf("https://cdn/${ada.hexEncodedString()}", grace), + typingAvatars(both, ChatType.GROUP, profiles, pictureUrl), + ) + + val adaStopped = both.filterNot { it.userId == ada }.toSet() + assertEquals( + listOf(grace), + typingAvatars(adaStopped, ChatType.GROUP, profiles, pictureUrl), + ) + } + + @Test + fun `a picture with no URL falls back to the id`() = runTest { + val avatars = typingAvatars( + typists = setOf(typing(ada, 100)), + chatType = ChatType.GROUP, + profiles = profiles, + pictureUrl = { _, _ -> null }, + ) + + assertEquals(listOf(ada), avatars) + } +} From ea8ef7e57650499b24e886e08d443ce3d0abb314 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 15:36:28 -0400 Subject: [PATCH 2/6] feat(chat): draw typing avatars through a slot, capped at three TypingIndicator now takes the typists, a key and an avatar slot rather than a list of image models. The row keeps its size, overlap, clip and animation; what goes in each circle is the caller's. The chat fills the slot with ContactAvatar, the same way the transcript draws a sender, so a member's picture goes through MediaUrlResolver's re-mint and BlurHash fallback, and a member without one shows initials. An unresolved profile draws the unknown-person avatar. Items are keyed on the user id, so an avatar no longer animates out and back in when a profile resolves. MediaUrlResolver comes back out of ChatViewModel. The row shows the last 3 typists, down from 10. --- .../app/messenger/internal/ChatViewModel.kt | 18 ++--- .../app/messenger/internal/TypingAvatars.kt | 40 +++++------ .../screens/components/ChatBottomBar.kt | 20 +++++- .../messenger/internal/TypingAvatarsTest.kt | 68 ++++++++----------- .../ui/components/chat/TypingIndicator.kt | 68 +++++++++++++------ 5 files changed, 115 insertions(+), 99 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0fce087762..226514a5f6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -20,7 +20,6 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.tokens.brandedName @@ -40,7 +39,6 @@ import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -122,7 +120,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -166,7 +163,6 @@ internal class ChatViewModel @Inject constructor( private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, private val chatDraftStore: ChatDraftStore, - private val mediaUrlResolver: MediaUrlResolver, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -204,7 +200,7 @@ internal class ChatViewModel @Inject constructor( val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), /** What the typing indicator draws ahead of its dots. See [typingAvatars]. */ - val typingAvatars: List = emptyList(), + val typingAvatars: List = emptyList(), val resolveState: ResolveState = ResolveState.Pending, val sendProgress: LoadingSuccessState = LoadingSuccessState(), val isSelfTyping: Boolean = false, @@ -473,7 +469,7 @@ internal class ChatViewModel @Inject constructor( data object OnStopMessageInput: Event data object OnMessageInputConsumed: Event data class TypistsUpdated(val typists: Set) : Event - data class TypingAvatarsUpdated(val avatars: List) : Event + data class TypingAvatarsUpdated(val avatars: List) : Event data object ResolveCompleted : Event data object ResolveFailed : Event @@ -1466,14 +1462,8 @@ internal class ChatViewModel @Inject constructor( typists.filter { it.userId.hexEncodedString() !in profiles } .forEach { chatCoordinator.requestSenderProfile(it.userId) } } - .mapLatest { (typists, chatType, profiles) -> - typingAvatars(typists, chatType, profiles.orEmpty()) { userId, picture -> - mediaUrlResolver.urlForSize( - media = picture, - targetLongestSidePx = TypingAvatarPx, - access = BlobAccessContext.profile(userId), - ) - } + .map { (typists, chatType, profiles) -> + typingAvatars(typists, chatType, profiles.orEmpty()) } .distinctUntilChanged() .onEach { dispatchEvent(Event.TypingAvatarsUpdated(it)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt index 8ba0197882..005088b61f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt @@ -2,45 +2,37 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.MediaItem import com.flipcash.shared.chat.ActiveTypist import com.getcode.opencode.model.core.ID import com.getcode.utils.hexEncodedString /** - * What `TypingIndicator` draws ahead of its dots: one entry per typist, oldest first, so the + * One face in the typing indicator. [profile] is null until the member's profile resolves, and the + * avatar draws the unknown-person fallback until then. + */ +internal data class TypingAvatar( + val userId: ID, + val profile: UserProfile?, +) { + /** Stable per user, and a String, so it can serve as the indicator's lazy item key. */ + val key: String get() = userId.hexEncodedString() +} + +/** + * What the typing indicator draws ahead of its dots: one entry per typist, oldest first, so the * indicator's `takeLast` keeps the people who started most recently. * * Group chats only. A DM or tip chat already names its one counterparty in the title bar, so it * keeps the dots on their own and this is empty. - * - * An entry is the typist's picture URL from [pictureUrl], or their user id when there is no URL to - * give — no picture, or a profile that hasn't resolved yet. `UserAvatar` draws its Person fallback - * for an id, and the id is stable per user, which the indicator's `key = hashCode()` relies on to - * keep each avatar in place as others join and leave. - * - * [pictureUrl] is handed the id alongside the picture because the id is what authorizes re-minting - * an expired download URL for someone else's avatar. */ -internal suspend fun typingAvatars( +internal fun typingAvatars( typists: Set, chatType: ChatType, profiles: Map, - pictureUrl: suspend (userId: ID, picture: MediaItem) -> String?, -): List { +): List { if (chatType != ChatType.GROUP) return emptyList() return typists .sortedWith(compareBy({ it.since }, { it.userId.hexEncodedString() })) - .map { typist -> - profiles[typist.userId.hexEncodedString()]?.profilePicture - ?.let { pictureUrl(typist.userId, it) } - ?: typist.userId - } + .map { typist -> TypingAvatar(typist.userId, profiles[typist.userId.hexEncodedString()]) } } - -/** - * The rendition size asked for. The indicator's avatars are `staticGrid.x8` (40dp), which is 160px - * at xxxhdpi and smaller below it, and 160 is one of the thumbnail sizes the server derives. - */ -internal const val TypingAvatarPx = 160 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 63ab2f5236..74dd58eae6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -51,11 +51,13 @@ import com.flipcash.app.messenger.internal.requiresStaff import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit +import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.ui.components.chat.TypingIndicator import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -130,10 +132,24 @@ internal fun UserControlBottomBar( ) { show -> if (show) { TypingIndicator( + typists = state.typingAvatars, + key = { it.key }, modifier = Modifier .hazeBlur(HazeInput.Sources(hazeState), material), - userImages = state.typingAvatars, - ) + ) { typist -> + // Drawn the way the transcript's sender gutter draws the same member, so the + // face beside the dots is the face beside their messages. + val profile = typist.profile + if (profile != null) { + ContactAvatar( + image = profile.profilePicture, + displayName = profile.displayName, + access = BlobAccessContext.profile(typist.userId), + ) + } else { + ContactAvatar(contact = null, includeBorder = false) + } + } } } Box { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt index be617490c2..a3d1274545 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/TypingAvatarsTest.kt @@ -2,13 +2,12 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.MediaItem import com.flipcash.shared.chat.ActiveTypist import com.getcode.opencode.model.core.ID import com.getcode.utils.hexEncodedString -import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -22,81 +21,74 @@ class TypingAvatarsTest { private val grace: ID = List(16) { 2 } private val linus: ID = List(16) { 3 } - private val adaPicture = MediaItem(renditions = emptyList()) + private val adaProfile = profile("Ada") + private val graceProfile = profile("Grace") private val profiles = mapOf( - ada.hexEncodedString() to profile("Ada", picture = adaPicture), - grace.hexEncodedString() to profile("Grace", picture = null), + ada.hexEncodedString() to adaProfile, + grace.hexEncodedString() to graceProfile, ) - private fun profile(name: String, picture: MediaItem?) = UserProfile( + private fun profile(name: String) = UserProfile( displayName = name, socialAccounts = emptyList(), phoneNumber = null, email = null, - profilePicture = picture, ) private fun typing(userId: ID, atMillis: Long) = ActiveTypist(userId, Instant.fromEpochMilliseconds(atMillis)) - private val pictureUrl: suspend (ID, MediaItem) -> String? = { userId, _ -> - "https://cdn/${userId.hexEncodedString()}" - } - @Test - fun `a group lists its typists oldest first`() = runTest { + fun `a group lists its typists oldest first`() { val avatars = typingAvatars( // Out of order on purpose: a set carries no order, so `since` has to supply it. typists = setOf(typing(linus, 300), typing(ada, 100), typing(grace, 200)), chatType = ChatType.GROUP, profiles = profiles, - pictureUrl = pictureUrl, ) - // Ada has a picture; Grace has a profile but no picture; Linus has no profile yet. The - // last two fall back to their ids, which is what draws the Person icon. - assertEquals(listOf("https://cdn/${ada.hexEncodedString()}", grace, linus), avatars) + // Linus has no profile yet, and is still listed so the fallback can hold his place. + assertEquals( + listOf( + TypingAvatar(ada, adaProfile), + TypingAvatar(grace, graceProfile), + TypingAvatar(linus, null), + ), + avatars, + ) } @Test - fun `a DM with a typist lists nobody`() = runTest { + fun `a DM with a typist lists nobody`() { val typists = setOf(typing(ada, 100)) - assertEquals( - emptyList(), - typingAvatars(typists, ChatType.CONTACT_DM, profiles, pictureUrl), - ) - assertEquals( - emptyList(), - typingAvatars(typists, ChatType.TIP_DM, profiles, pictureUrl), - ) + assertTrue(typingAvatars(typists, ChatType.CONTACT_DM, profiles).isEmpty()) + assertTrue(typingAvatars(typists, ChatType.TIP_DM, profiles).isEmpty()) } @Test - fun `a typist who stops typing drops out`() = runTest { + fun `a typist who stops typing drops out`() { val both = setOf(typing(ada, 100), typing(grace, 200)) assertEquals( - listOf("https://cdn/${ada.hexEncodedString()}", grace), - typingAvatars(both, ChatType.GROUP, profiles, pictureUrl), + listOf(ada, grace), + typingAvatars(both, ChatType.GROUP, profiles).map { it.userId }, ) val adaStopped = both.filterNot { it.userId == ada }.toSet() assertEquals( - listOf(grace), - typingAvatars(adaStopped, ChatType.GROUP, profiles, pictureUrl), + listOf(grace), + typingAvatars(adaStopped, ChatType.GROUP, profiles).map { it.userId }, ) } @Test - fun `a picture with no URL falls back to the id`() = runTest { - val avatars = typingAvatars( - typists = setOf(typing(ada, 100)), - chatType = ChatType.GROUP, - profiles = profiles, - pictureUrl = { _, _ -> null }, - ) + fun `a typist keeps their key when their profile resolves`() { + // The indicator keys its lazy items on this; a key that moved when the profile landed + // would animate the avatar out and back in mid-typing. + val before = TypingAvatar(ada, profile = null) + val after = TypingAvatar(ada, adaProfile) - assertEquals(listOf(ada), avatars) + assertEquals(before.key, after.key) } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index de46a11327..daf83632a4 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -60,20 +60,33 @@ import com.getcode.ui.utils.IDPreviewParameterProvider import kotlinx.coroutines.delay import kotlin.random.Random +/** + * Dots in a bubble, led by an overlapping row of the last [MaxAvatars] of [typists]. An empty + * [typists] draws the dots alone. + * + * [avatar] draws one typist inside a circle the row sizes, clips and animates; it is measured to + * fill that circle. [key] identifies a typist across updates, so an avatar stays in place as others + * join and leave rather than animating out and back in. It becomes the lazy item key, so it has to + * be a type Android can save in a Bundle. + */ @Composable -fun TypingIndicator( +fun TypingIndicator( + typists: List, + key: (T) -> Any, modifier: Modifier = Modifier, - userImages: List = emptyList(), + avatar: @Composable (T) -> Unit, ) { Row( modifier = Modifier .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (userImages.isNotEmpty()) { + if (typists.isNotEmpty()) { AvatarRow( modifier = Modifier.weight(1f, fill = false), - userImages = userImages, + typists = typists, + key = key, + avatar = avatar, ) } @@ -82,8 +95,10 @@ fun TypingIndicator( } @Composable -private fun AvatarRow( - userImages: List, +private fun AvatarRow( + typists: List, + key: (T) -> Any, + avatar: @Composable (T) -> Unit, modifier: Modifier = Modifier ) { val avatarSize = CodeTheme.dimens.staticGrid.x8 @@ -101,13 +116,13 @@ private fun AvatarRow( contentPadding = PaddingValues(end = CodeTheme.dimens.grid.x2) ) { itemsIndexed( - items = userImages.takeLast(MaxAvatars), - key = { _, item -> item.hashCode() } - ) { index, image -> - UserAvatar( + items = typists.takeLast(MaxAvatars), + key = { _, item -> key(item) } + ) { index, item -> + Box( modifier = Modifier .size(avatarSize) - .zIndex(userImages.takeLast(MaxAvatars).count() - index.toFloat()) + .zIndex(typists.takeLast(MaxAvatars).count() - index.toFloat()) .clip(CircleShape) .animateItem( fadeOutSpec = spring( @@ -115,14 +130,9 @@ private fun AvatarRow( visibilityThreshold = 0.5f ) ), - data = image + propagateMinConstraints = true, ) { - Image( - modifier = Modifier.padding(5.dp), - imageVector = Icons.Default.Person, - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null - ) + avatar(item) } } } @@ -199,7 +209,7 @@ private fun TypingDots( } } -private const val MaxAvatars = 10 +private const val MaxAvatars = 3 private const val DotCount = 3 private val DotSize = 7.dp @@ -254,7 +264,9 @@ fun PreviewTypingIndicator() { modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.grid.x2), - userImages = users + typists = users, + key = { it.hashCode() }, + avatar = { PreviewAvatar(it) }, ) } } @@ -270,7 +282,9 @@ private fun TestAvatarRow() { DesignSystem { Column { AvatarRow( - userImages = imageList, + typists = imageList, + key = { it.hashCode() }, + avatar = { PreviewAvatar(it) }, ) Button(onClick = { imageList = imageList + "user${imageList.size + 1}" @@ -287,6 +301,18 @@ private fun TestAvatarRow() { } } +@Composable +private fun PreviewAvatar(data: Any) { + UserAvatar(data = data) { + Image( + modifier = Modifier.padding(5.dp), + imageVector = Icons.Default.Person, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null + ) + } +} + private fun randomColor(): Color { return Color( red = Random.nextFloat(), From a740219b923e4459d59677f87dfae40571775daa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 15:49:50 -0400 Subject: [PATCH 3/6] fix(chat): stack typing avatars newest-on-top with a cutout ring The LazyRow clipped avatars on their way out (its own scroll clip and animateContentSize both clip), dropped a leaving avatar from its width before the fade finished, and put the oldest typist on top. AvatarStack replaces it. Each avatar keeps an animated slot and presence, the stack's width follows the animated count, and the newest typist sits on top at the right. A newcomer grows from its left edge at its final slot, starting at 0.4 scale so it stays inside the stack's width and clear of the bubble. A leaver drops to the bottom while its neighbours slide over it. Each avatar cuts a 2dp transparent ring out of the ones beneath it, rather than painting a ring in a fixed color, so the separation holds over the blurred transcript. Avatars are 24dp with an 8dp overlap and a 4dp gap to the bubble, matching the sender avatar beside a group's messages. The preview now scripts joins and leaves so it can be run and recorded. --- .../ui/components/chat/TypingIndicator.kt | 320 ++++++++++-------- 1 file changed, 187 insertions(+), 133 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index daf83632a4..eb76f81d24 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -1,11 +1,12 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition @@ -14,31 +15,23 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Button -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,28 +39,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import com.getcode.theme.CodeTheme import com.getcode.theme.DesignSystem -import com.getcode.ui.components.PriceWithFlagDefaults.Text -import com.getcode.ui.utils.IDPreviewParameterProvider import kotlinx.coroutines.delay -import kotlin.random.Random +import kotlinx.coroutines.launch +import kotlin.math.roundToInt /** - * Dots in a bubble, led by an overlapping row of the last [MaxAvatars] of [typists]. An empty + * Dots in a bubble, led by an overlapping stack of the last [MaxAvatars] of [typists]. An empty * [typists] draws the dots alone. * - * [avatar] draws one typist inside a circle the row sizes, clips and animates; it is measured to - * fill that circle. [key] identifies a typist across updates, so an avatar stays in place as others - * join and leave rather than animating out and back in. It becomes the lazy item key, so it has to - * be a type Android can save in a Bundle. + * The stack reads left to right: the oldest typist sits at the back on the left and the newest on + * top at the right. [avatar] draws one typist inside a circle the stack sizes, clips and animates; + * it is measured to fill that circle. [key] identifies a typist across updates, so an avatar slides + * to its new place as others join and leave rather than animating out and back in. */ @Composable fun TypingIndicator( @@ -81,58 +81,138 @@ fun TypingIndicator( .padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (typists.isNotEmpty()) { - AvatarRow( - modifier = Modifier.weight(1f, fill = false), - typists = typists, - key = key, - avatar = avatar, - ) - } + AvatarStack( + typists = typists, + key = key, + avatar = avatar, + ) TypingDots(modifier = modifier) } } +/** One avatar in the stack, kept after its typist leaves until it has animated out. */ +private class StackEntry( + val key: Any, + item: T, + slot: Float, + presence: Float, +) { + var item by mutableStateOf(item) + var leaving by mutableStateOf(false) + val slot = Animatable(slot) + val presence = Animatable(presence) +} + @Composable -private fun AvatarRow( +private fun AvatarStack( typists: List, key: (T) -> Any, avatar: @Composable (T) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val avatarSize = CodeTheme.dimens.staticGrid.x8 - val overlap = CodeTheme.dimens.staticGrid.x4 + val avatarSize = AvatarSize + val step = avatarSize - AvatarOverlap + val gap = CodeTheme.dimens.grid.x1 + val density = LocalDensity.current + val stepPx = with(density) { step.toPx() } + val ringPx = with(density) { AvatarRing.toPx() } + + val shown = typists.takeLast(MaxAvatars) + // Avatars on the first frame are part of the indicator's own entrance, so they start in place + // rather than growing out of it a second time. + val entries = remember { + mutableStateListOf>().apply { + shown.forEachIndexed { index, item -> add(StackEntry(key(item), item, index.toFloat(), 1f)) } + } + } + val shownKeys = shown.map(key) + val count by animateFloatAsState( + targetValue = shown.size.toFloat(), + animationSpec = AvatarMotion, + label = "avatarCount", + ) + + LaunchedEffect(shownKeys) { + shown.forEachIndexed { index, item -> + val itemKey = key(item) + val entry = entries.firstOrNull { it.key == itemKey && !it.leaving } + ?: StackEntry(itemKey, item, index.toFloat(), 0f).also { entries.add(it) } + entry.item = item + launch { entry.slot.animateTo(index.toFloat(), AvatarMotion) } + launch { entry.presence.animateTo(1f, AvatarMotion) } + } + entries.filter { !it.leaving && it.key !in shownKeys }.forEach { entry -> + entry.leaving = true + launch { + entry.presence.animateTo(0f, AvatarMotion) + entries.remove(entry) + } + } + } + + // Stacking order: later slots on top, and a leaving avatar beneath everything, so the ones + // that close the gap slide over it. + fun StackEntry.z(): Float = if (leaving) -1f else slot.value + + // Avatars grow from and shrink to their left edge. From AvatarEnterScale up, that keeps an + // arriving avatar's right edge inside the stack's animated width, clear of the bubble. + fun StackEntry.scale(): Float = lerp(AvatarEnterScale, 1f, presence.value) - LazyRow( + Box( modifier = modifier - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - clip = false } - .padding(vertical = 4.dp) - .animateContentSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(-overlap, Alignment.Start), - contentPadding = PaddingValues(end = CodeTheme.dimens.grid.x2) + .layout { measurable, constraints -> + // n avatars are n steps plus one overlap wide; the gap to the bubble grows in with + // the first avatar, so the dots never jump. + val c = count + val width = (c * step.toPx() + (AvatarOverlap + gap).toPx() * c.coerceAtMost(1f)) + .roundToInt() + val placeable = measurable.measure(constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity)) + layout(width, placeable.height) { placeable.place(0, 0) } + }, ) { - itemsIndexed( - items = typists.takeLast(MaxAvatars), - key = { _, item -> key(item) } - ) { index, item -> - Box( - modifier = Modifier - .size(avatarSize) - .zIndex(typists.takeLast(MaxAvatars).count() - index.toFloat()) - .clip(CircleShape) - .animateItem( - fadeOutSpec = spring( - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = 0.5f - ) - ), - propagateMinConstraints = true, - ) { - avatar(item) + entries.forEach { entry -> + key(entry.key) { + Box( + modifier = Modifier + .zIndex(entry.z()) + .offset { IntOffset((entry.slot.value * stepPx).roundToInt(), 0) } + .size(avatarSize) + .graphicsLayer { + val scale = entry.scale() + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0.5f) + alpha = entry.presence.value + // Its own buffer, so the rings cut below clear only this avatar. + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + // Cut a ring where each avatar above this one sits, so overlapping + // faces stay apart over any background. + // Worked in this layer's unscaled space, which is scaled about its left edge. + val myScale = entry.scale() + val myZ = entry.z() + val radius = size.minDimension / 2 + entries.forEach { other -> + if (other === entry || other.z() <= myZ) return@forEach + val otherScale = other.scale() + val otherCenterX = + (other.slot.value - entry.slot.value) * stepPx + radius * otherScale + drawCircle( + color = Color.Black.copy(alpha = other.presence.value), + radius = (radius * otherScale + ringPx) / myScale, + center = Offset(otherCenterX / myScale, center.y), + blendMode = BlendMode.DstOut, + ) + } + } + .clip(CircleShape), + propagateMinConstraints = true, + ) { + avatar(entry.item) + } } } } @@ -210,6 +290,15 @@ private fun TypingDots( } private const val MaxAvatars = 3 +private const val AvatarEnterScale = 0.4f +// Matches the sender avatar beside a group's messages. +private val AvatarSize = 24.dp +private val AvatarOverlap = 8.dp +private val AvatarRing = 2.dp +private val AvatarMotion = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) private const val DotCount = 3 private val DotSize = 7.dp @@ -222,50 +311,52 @@ private const val DotFall = 300 // ms — time to fall back to base op private const val WaveBaseOpacity = 0.3f private const val WavePeakOpacity = 0.85f +/** + * Scripted joins and leaves: the first three typists arrive one by one, a fourth pushes the oldest + * off the back, one in the middle stops, then everyone stops. + */ @Composable @Preview fun PreviewTypingIndicator() { - val provider = IDPreviewParameterProvider(MaxAvatars + 2) - - var userCount by remember { mutableIntStateOf(0) } - - // Animation logic + val script = remember { + listOf( + listOf("A"), + listOf("A", "B"), + listOf("A", "B", "C"), + listOf("A", "B", "C", "D"), + listOf("B", "D"), + listOf("D"), + emptyList(), + ) + } + var step by remember { mutableIntStateOf(0) } LaunchedEffect(Unit) { while (true) { - // Ramp up from 0 to max - for (i in 0..MaxAvatars) { - userCount = i - delay(2000L) // 2-second delay - } - // Ramp down from max to 0 - for (i in MaxAvatars downTo 0) { - userCount = i - delay(2000L) // 2-second delay - } + delay(1600L) + step = (step + 1) % script.size } } + val users = script[step] - val users by remember(userCount) { derivedStateOf { provider.values.toList().take(userCount) } } DesignSystem { - Box(modifier = Modifier.size(400.dp), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(width = 360.dp, height = 160.dp) + .background(CodeTheme.colors.background), + contentAlignment = Alignment.CenterStart, + ) { AnimatedContent( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), targetState = users.isNotEmpty(), transitionSpec = { - slideInVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ) - ) { it } + scaleIn() + fadeIn() togetherWith fadeOut() + scaleOut() + slideOutVertically { it } - } + (scaleIn(initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeIn()) togetherWith + (scaleOut(targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeOut()) + }, ) { show -> if (show) { TypingIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x2), typists = users, - key = { it.hashCode() }, + key = { it }, avatar = { PreviewAvatar(it) }, ) } @@ -275,49 +366,12 @@ fun PreviewTypingIndicator() { } @Composable -@Preview -private fun TestAvatarRow() { - var imageList by remember { mutableStateOf(listOf("user1", "user2", "user3")) } - - DesignSystem { - Column { - AvatarRow( - typists = imageList, - key = { it.hashCode() }, - avatar = { PreviewAvatar(it) }, - ) - Button(onClick = { - imageList = imageList + "user${imageList.size + 1}" - }) { - Text("Add Avatar") - } - - Button(onClick = { - imageList = imageList.drop(1) - }) { - Text("Remove Avatar") - } - } - } -} - -@Composable -private fun PreviewAvatar(data: Any) { - UserAvatar(data = data) { - Image( - modifier = Modifier.padding(5.dp), - imageVector = Icons.Default.Person, - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null - ) +private fun PreviewAvatar(name: String) { + val colors = listOf(Color(0xFFE0A458), Color(0xFF5B8DEF), Color(0xFF3FB68B), Color(0xFFD9637C)) + Box( + modifier = Modifier.background(colors[(name.first() - 'A') % colors.size]), + contentAlignment = Alignment.Center, + ) { + Text(text = name, color = Color.White, style = CodeTheme.typography.caption) } } - -private fun randomColor(): Color { - return Color( - red = Random.nextFloat(), - green = Random.nextFloat(), - blue = Random.nextFloat(), - alpha = 1f - ) -} \ No newline at end of file From 0fc2b365042f65464cdbf7f503e99c528c12ac36 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 17:42:41 -0400 Subject: [PATCH 4/6] fix(chat): stop clipping the typing indicator as it leaves The AnimatedContent around the indicator shrinks to nothing on exit and clips to its bounds while it does, so the bubble and avatars were cut off partway through their fade. SizeTransform(clip = false) lets the indicator fade out whole. The preview's transition gets the same change. --- .../messenger/internal/screens/components/ChatBottomBar.kt | 6 +++++- .../com/getcode/ui/components/chat/TypingIndicator.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 74dd58eae6..4ba0827a5b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition @@ -127,7 +128,10 @@ internal fun UserControlBottomBar( ChatAnimations.typingIndicator, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f) - ) + fadeOut(ChatAnimations.typingIndicator)) + ) + fadeOut(ChatAnimations.typingIndicator)) using + // The container shrinks to nothing as the indicator leaves; unclipped, the + // indicator fades out whole instead of being cut off by the shrinking bounds. + SizeTransform(clip = false) } ) { show -> if (show) { diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index eb76f81d24..a311cb38d3 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -1,6 +1,7 @@ package com.getcode.ui.components.chat import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.RepeatMode @@ -350,7 +351,8 @@ fun PreviewTypingIndicator() { targetState = users.isNotEmpty(), transitionSpec = { (scaleIn(initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeIn()) togetherWith - (scaleOut(targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeOut()) + (scaleOut(targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + fadeOut()) using + SizeTransform(clip = false) }, ) { show -> if (show) { From 08872dbc96c6b403944e1ed0898ce7ffb5b95749 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 18:21:02 -0400 Subject: [PATCH 5/6] fix(chat): draw an unresolved typist like an unnamed sender A typist whose profile hasn't loaded now goes through the same ContactAvatar call as a resolved one, with no picture and an empty name, instead of the unknown-person glyph. --- .../app/messenger/internal/TypingAvatars.kt | 2 +- .../screens/components/ChatBottomBar.kt | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt index 005088b61f..5637ffbf07 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/TypingAvatars.kt @@ -8,7 +8,7 @@ import com.getcode.utils.hexEncodedString /** * One face in the typing indicator. [profile] is null until the member's profile resolves, and the - * avatar draws the unknown-person fallback until then. + * avatar draws what the transcript draws for a sender with no picture and no name until then. */ internal data class TypingAvatar( val userId: ID, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 4ba0827a5b..fe0b13faae 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -142,17 +142,15 @@ internal fun UserControlBottomBar( .hazeBlur(HazeInput.Sources(hazeState), material), ) { typist -> // Drawn the way the transcript's sender gutter draws the same member, so the - // face beside the dots is the face beside their messages. + // face beside the dots is the face beside their messages. A typist whose + // profile hasn't resolved goes through the same call with no picture and no + // name, rather than a separate unknown-person glyph. val profile = typist.profile - if (profile != null) { - ContactAvatar( - image = profile.profilePicture, - displayName = profile.displayName, - access = BlobAccessContext.profile(typist.userId), - ) - } else { - ContactAvatar(contact = null, includeBorder = false) - } + ContactAvatar( + image = profile?.profilePicture, + displayName = profile?.displayName.orEmpty(), + access = BlobAccessContext.profile(typist.userId), + ) } } } From f2cdf06fafce1cb90ccde40beec8dcc344c7926f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:26:44 -0400 Subject: [PATCH 6/6] fix(ui): leave an avatar with no name as the bare gradient InitialsText fell back to "?" when the name had no letters. It now draws nothing, matching iOS, which shows only the gradient. --- .../main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 2fee4b4565..287e342fb7 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 @@ -331,8 +331,9 @@ private fun BoxWithConstraintsScope.InitialsText(displayName: String) { .take(2) .mapNotNull { it.firstOrNull()?.uppercaseChar() } .joinToString("") - .ifEmpty { "?" } } + // A name with no letters leaves the gradient on its own, as iOS does, rather than a "?". + if (initials.isEmpty()) return val fontSize = with(LocalDensity.current) { (min(maxWidth, maxHeight) * 0.38f).toSp() }