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 ->