From 140d992ada8f68909b1e86da1c240ca1b275915c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 10 Jul 2026 11:17:26 -0400 Subject: [PATCH] feat(send/contactlist): show typing state in message preview Fold real-time typing indicators into the send-flow contact list, and pay down the accompanying bloat in the list model and ViewModel: - Regroup the chat-derived fields on ContactListItem.ContactRow into a nullable Conversation (chatId, lastMessagePreview, unreadCount, isTyping). lastActivity stays on the row since it's also the sort key for on-Flipcash contacts that have no chat yet. - Extract generateListItems/formatPreview out of SendFlowViewModel into a pure, injectable ContactListBuilder, shrinking the ViewModel and making the list logic unit-testable on its own. - Apply typing as a cheap second-stage flow overlay on top of the base list (distinctUntilChanged typing keys, self filtered out) so typing ticks never re-filter or re-sort the list. Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 1 + .../directsend/internal/ContactListBuilder.kt | 181 ++++++++++++++++ .../directsend/internal/ContactListItem.kt | 24 ++- .../directsend/internal/SendFlowViewModel.kt | 202 ++++-------------- .../screens/ContactsPermissionGateScreen.kt | 2 +- .../screens/components/ContactList.kt | 20 +- 6 files changed, 258 insertions(+), 172 deletions(-) create mode 100644 apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e34b874a9..4aca3a74e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -827,6 +827,7 @@ %1$s of %2$s You sent %1$s You received %1$s + Is Typing… Unknown Contact diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt new file mode 100644 index 000000000..5416391c4 --- /dev/null +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt @@ -0,0 +1,181 @@ +package com.flipcash.app.directsend.internal + +import com.flipcash.app.contacts.ContactCoordinator.ContactState +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.phone.PhoneUtils +import com.flipcash.features.directsend.R +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatSummary +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import javax.inject.Inject + +/** + * Builds the send-flow contact list from the raw inputs: device contacts, the + * chat feed, the current search string, and known tokens. + * + * Pure and free of the ViewModel/flow machinery so it can be unit-tested + * directly. Ephemeral overlays (e.g. typing indicators) are applied on top of + * this output separately — see [SendFlowViewModel] — so they don't force a + * re-filter/re-sort of the whole list. + */ +internal class ContactListBuilder @Inject constructor( + private val userManager: UserManager, + private val phoneUtils: PhoneUtils, + private val resources: ResourceHelper, +) { + + fun build( + contactState: ContactState, + searchString: String, + chatFeed: List, + tokensByMint: Map, + ): List = buildList { + val selfId = userManager.accountId + val selfPhone = userManager.profile?.verifiedPhoneNumber + + val allContacts = contactState.contacts.values + .filter { selfPhone == null || it.e164 != selfPhone } + val filtered = if (searchString.isBlank()) { + allContacts + } else { + allContacts.filter { + it.displayName.contains(searchString, ignoreCase = true) || + it.e164.contains(searchString, ignoreCase = true) + } + } + + val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM } + // Build a reverse lookup: e164 -> chatId string for contacts with DMs + val e164ToChatId = contactState.dmChatIds + + // Recents — driven by the chat feed, enriched with contact info + val recentsE164s = mutableSetOf() + val recentRows = dmChats.mapNotNull { summary -> + val chatId = summary.metadata.chatId + val chatIdStr = chatId.toString() + + // Try to match this chat to a device contact + val e164 = e164ToChatId.entries + .firstOrNull { it.value == chatIdStr }?.key + val deviceContact = e164 + ?.takeIf { selfPhone == null || it != selfPhone } + ?.let { contactState.contacts[it] } + + val contact = if (deviceContact != null) { + if (searchString.isNotBlank() && + !deviceContact.displayName.contains(searchString, ignoreCase = true) && + !deviceContact.e164.contains(searchString, ignoreCase = true) + ) { + return@mapNotNull null + } + recentsE164s += deviceContact.e164 + deviceContact + } else { + // Non-contact DM — build contact from chat member profile + val isSelf = { member: ChatMember -> + member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone) + } + val otherMember = summary.metadata.members + .firstOrNull { !isSelf(it) } ?: return@mapNotNull null + val phone = otherMember.userProfile.verifiedPhoneNumber + val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } + val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } + ?: formattedPhone + ?: return@mapNotNull null + + val unknown = DeviceContact.unknownContact( + e164 = phone.orEmpty(), + displayName = displayName, + displayNumber = formattedPhone, + ) + if (searchString.isNotBlank() && + !unknown.displayName.contains(searchString, ignoreCase = true) + ) { + return@mapNotNull null + } + if (unknown.e164.isNotEmpty()) { + recentsE164s += unknown.e164 + } + unknown + } + + ContactListItem.ContactRow( + contact = contact, + isOnFlipcash = true, + lastActivity = summary.metadata.lastActivity, + conversation = Conversation( + chatId = chatId, + lastMessagePreview = formatPreview(summary, selfId, tokensByMint), + unreadCount = summary.unreadCount, + ), + ) + } + + // On Flipcash — contacts that haven't chatted yet, use joinedAt as their sort timestamp + val flipcashRows = filtered + .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } + .map { contact -> + ContactListItem.ContactRow( + contact = contact, + isOnFlipcash = true, + lastActivity = contactState.joinedAtByE164[contact.e164], + ) + } + + val flipcashCombined = (recentRows + flipcashRows).sortedWith( + compareByDescending { it.lastActivity } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } + ) + + val excludedE164s = recentsE164s + contactState.flipcashE164s + val other = filtered + .filter { it.e164 !in excludedE164s } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + + addAll(flipcashCombined) + if (other.isNotEmpty()) { + add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) + other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } + } + } + + private fun formatPreview( + summary: ChatSummary, + selfId: ID?, + tokensByMint: Map, + ): String? { + val lastMsg = summary.metadata.lastMessage ?: return null + val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId + return lastMsg.content.firstOrNull()?.let { content -> + when (content) { + is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() } + is MessageContent.Cash -> { + val formatted = content.amount.formatted() + val name = content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } + val label = if (name.isNotBlank()) { + resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) + } else { + formatted + } + if (sentBySelf) { + resources.getString(R.string.label_chat_preview_sentCash, label) + } else { + resources.getString(R.string.label_chat_preview_receivedCash, label) + } + } + + // TODO: + is MessageContent.Deleted -> null + is MessageContent.Media -> null + is MessageContent.Reply -> null + is MessageContent.System -> null + } + } + } +} diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt index 619888efa..388457f8e 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt @@ -6,12 +6,30 @@ import kotlin.time.Instant internal sealed interface ContactListItem { data class Header(val title: String) : ContactListItem + + /** + * A single contact in the send list. + * + * [lastActivity] is the sort key and is present for any row that should be + * ranked by recency — a chat's last activity for recents, or the contact's + * joinedAt for on-Flipcash contacts without a chat yet — so it lives on the + * row rather than inside [conversation]. + * + * [conversation] holds everything derived from an existing DM and is `null` + * for contacts with no chat (on-Flipcash-but-never-messaged, or off-Flipcash). + */ data class ContactRow( val contact: DeviceContact, val isOnFlipcash: Boolean, - val lastMessagePreview: String? = null, - val unreadCount: Int = 0, - val chatId: ChatId? = null, val lastActivity: Instant? = null, + val conversation: Conversation? = null, ) : ContactListItem } + +/** Presentation state derived from an existing DM with a contact. */ +internal data class Conversation( + val chatId: ChatId, + val lastMessagePreview: String? = null, + val unreadCount: Int = 0, + val isTyping: Boolean = false, +) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index d3f3a4a73..99121b842 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope import com.flipcash.app.contacts.ContactCoordinator -import com.flipcash.app.contacts.ContactCoordinator.ContactState import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.contacts.DeviceContact @@ -13,19 +12,11 @@ import com.flipcash.app.core.send.SendStep import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.permissions.PickedContact -import com.flipcash.app.phone.PhoneUtils import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.directsend.R -import com.flipcash.services.models.chat.ChatMember -import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.ChatSummary import com.getcode.manager.BottomBarManager -import com.getcode.opencode.model.core.ID -import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState @@ -50,7 +41,7 @@ internal class SendFlowViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, chatCoordinator: ChatCoordinator, tokenCoordinator: TokenCoordinator, - private val phoneUtils: PhoneUtils, + private val contactListBuilder: ContactListBuilder, private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), @@ -113,7 +104,9 @@ internal class SendFlowViewModel @Inject constructor( dispatchEvent(event) }.launchIn(viewModelScope) - combine( + // Base list — expensive to build (filter + sort), so it only recomputes + // when contacts, search, the chat feed, or tokens change. + val baseItems = combine( contactCoordinator.state, stateFlow .map { it.searchState } @@ -122,8 +115,40 @@ internal class SendFlowViewModel @Inject constructor( chatCoordinator.feed, tokenCoordinator.tokens, ) { contactState, searchText, chatFeed, tokens -> - val tokensByMint = tokens.associateBy { it.address } - generateListItems(contactState, searchText.toString(), chatFeed, tokensByMint) + contactListBuilder.build( + contactState = contactState, + searchString = searchText.toString(), + chatFeed = chatFeed, + tokensByMint = tokens.associateBy { it.address }, + ) + } + + // Typing is ephemeral and flips on/off frequently — overlay it on top of + // the base list with a cheap map rather than rebuilding + re-sorting the + // whole list on every typing tick. distinctUntilChanged means we only + // recompute when the set of typing chats changes. + val typingChatIds = chatCoordinator.state + .map { chatState -> + val selfId = userManager.accountId + chatState.typingIndicators + .filterValues { typists -> typists.any { it.userId != selfId } } + .keys + } + .distinctUntilChanged() + + combine(baseItems, typingChatIds) { items, typing -> + if (typing.isEmpty()) { + items + } else { + items.map { item -> + val conversation = (item as? ContactListItem.ContactRow)?.conversation + if (conversation != null && conversation.chatId in typing) { + item.copy(conversation = conversation.copy(isTyping = true)) + } else { + item + } + } + } }.onEach { items -> dispatchEvent(Event.OnItemsPopulated(items)) }.launchIn(viewModelScope) @@ -180,10 +205,10 @@ internal class SendFlowViewModel @Inject constructor( val identifier = if (contact.e164.isNotEmpty()) { ChatIdentifier.ByContact( contact = contact, - chatId = row.chatId + chatId = row.conversation?.chatId ) } else { - ChatIdentifier.ByChatId(row.chatId!!) + ChatIdentifier.ByChatId(row.conversation!!.chatId) } dispatchEvent(Event.NavigateToChat(identifier)) } else { @@ -225,153 +250,6 @@ internal class SendFlowViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun generateListItems( - contactState: ContactState, - searchString: String, - chatFeed: List, - tokensByMint: Map, - ): List = buildList { - val selfId = userManager.accountId - val selfPhone = userManager.profile?.verifiedPhoneNumber - - val allContacts = contactState.contacts.values - .filter { selfPhone == null || it.e164 != selfPhone } - val filtered = if (searchString.isBlank()) { - allContacts - } else { - allContacts.filter { - it.displayName.contains(searchString, ignoreCase = true) || - it.e164.contains(searchString, ignoreCase = true) - } - } - - val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM } - // Build a reverse lookup: e164 -> chatId string for contacts with DMs - val e164ToChatId = contactState.dmChatIds - - // Recents — driven by the chat feed, enriched with contact info - val recentsE164s = mutableSetOf() - val recentRows = dmChats.mapNotNull { summary -> - val chatId = summary.metadata.chatId - val chatIdStr = chatId.toString() - - // Try to match this chat to a device contact - val e164 = e164ToChatId.entries - .firstOrNull { it.value == chatIdStr }?.key - val deviceContact = e164 - ?.takeIf { selfPhone == null || it != selfPhone } - ?.let { contactState.contacts[it] } - - val contact = if (deviceContact != null) { - if (searchString.isNotBlank() && - !deviceContact.displayName.contains(searchString, ignoreCase = true) && - !deviceContact.e164.contains(searchString, ignoreCase = true) - ) { - return@mapNotNull null - } - recentsE164s += deviceContact.e164 - deviceContact - } else { - // Non-contact DM — build contact from chat member profile - val isSelf = { member: ChatMember -> - member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone) - } - val otherMember = summary.metadata.members - .firstOrNull { !isSelf(it) } ?: return@mapNotNull null - val phone = otherMember.userProfile.verifiedPhoneNumber - val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } - val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } - ?: formattedPhone - ?: return@mapNotNull null - - val unknown = DeviceContact.unknownContact( - e164 = phone.orEmpty(), - displayName = displayName, - displayNumber = formattedPhone, - ) - if (searchString.isNotBlank() && - !unknown.displayName.contains(searchString, ignoreCase = true) - ) { - return@mapNotNull null - } - if (unknown.e164.isNotEmpty()) { - recentsE164s += unknown.e164 - } - unknown - } - - ContactListItem.ContactRow( - contact = contact, - isOnFlipcash = true, - lastMessagePreview = formatPreview(summary, selfId, tokensByMint), - unreadCount = summary.unreadCount, - chatId = chatId, - lastActivity = summary.metadata.lastActivity, - ) - } - - // On Flipcash — contacts that haven't chatted yet, use joinedAt as their sort timestamp - val flipcashRows = filtered - .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } - .map { contact -> - ContactListItem.ContactRow( - contact = contact, - isOnFlipcash = true, - lastActivity = contactState.joinedAtByE164[contact.e164], - ) - } - - val flipcashCombined = (recentRows + flipcashRows).sortedWith( - compareByDescending { it.lastActivity } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } - ) - - val excludedE164s = recentsE164s + contactState.flipcashE164s - val other = filtered - .filter { it.e164 !in excludedE164s } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - - addAll(flipcashCombined) - if (other.isNotEmpty()) { - add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) - other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } - } - } - - private fun formatPreview( - summary: ChatSummary, - selfId: ID?, - tokensByMint: Map, - ): String? { - val lastMsg = summary.metadata.lastMessage ?: return null - val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId - return lastMsg.content.firstOrNull()?.let { content -> - when (content) { - is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() } - is MessageContent.Cash -> { - val formatted = content.amount.formatted() - val name = content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } - val label = if (name.isNotBlank()) { - resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) - } else { - formatted - } - if (sentBySelf) { - resources.getString(R.string.label_chat_preview_sentCash, label) - } else { - resources.getString(R.string.label_chat_preview_receivedCash, label) - } - } - - // TODO: - is MessageContent.Deleted -> null - is MessageContent.Media -> null - is MessageContent.Reply -> null - is MessageContent.System -> null - } - } - } - companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt index 7a7fd0e98..c10feada0 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactsPermissionGateScreen.kt @@ -95,7 +95,7 @@ internal fun ContactsPermissionGateScreen() { return } - val hasChats = state.listItems.any { it is ContactListItem.ContactRow && it.chatId != null } + val hasChats = state.listItems.any { it is ContactListItem.ContactRow && it.conversation != null } // If denied but user has existing chats, skip the gate entirely if (showRationale && hasChats) { diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt index ebd434db7..e9f049ac6 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt @@ -115,7 +115,8 @@ internal fun ContactList( key = { _, item -> when (item) { is ContactListItem.Header -> item.title - is ContactListItem.ContactRow -> item.chatId?.toString() ?: item.contact.e164 + is ContactListItem.ContactRow -> item.conversation?.chatId?.toString() + ?: item.contact.e164 } } ) { index, item -> @@ -133,8 +134,9 @@ internal fun ContactList( ContactRowItem( contact = item.contact, isOnFlipcash = item.isOnFlipcash, - lastMessagePreview = item.lastMessagePreview, - unreadCount = item.unreadCount, + lastMessagePreview = item.conversation?.lastMessagePreview, + unreadCount = item.conversation?.unreadCount ?: 0, + isTyping = item.conversation?.isTyping == true, showDivider = !isLastInSection, lastActivity = item.lastActivity, ) { @@ -332,6 +334,7 @@ private fun ContactRowItem( lastMessagePreview: String? = null, lastActivity: Instant? = null, unreadCount: Int = 0, + isTyping: Boolean = false, showDivider: Boolean = true, onClick: () -> Unit, ) { @@ -422,8 +425,14 @@ private fun ContactRowItem( val showSubtitle = lastMessagePreview != null || !isOnFlipcash - if (showSubtitle) { - Text( + when { + isTyping -> Text( + text = stringResource(R.string.label_isTyping), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + + showSubtitle -> Text( text = if (isOnFlipcash && !lastMessagePreview.isNullOrEmpty()) { lastMessagePreview } else { @@ -589,7 +598,6 @@ private fun ContactListPreview() { displayNumber = "(555) 000-${1000 + i}", ), isOnFlipcash = true, - unreadCount = 12, ) } val otherContacts = fakeNames.drop(6).mapIndexed { i, name ->