|
| 1 | +package com.flipcash.app.directsend.internal |
| 2 | + |
| 3 | +import com.flipcash.app.contacts.ContactCoordinator.ContactState |
| 4 | +import com.flipcash.app.core.contacts.DeviceContact |
| 5 | +import com.flipcash.app.phone.PhoneUtils |
| 6 | +import com.flipcash.features.directsend.R |
| 7 | +import com.flipcash.services.models.chat.ChatMember |
| 8 | +import com.flipcash.services.models.chat.ChatType |
| 9 | +import com.flipcash.services.models.chat.MessageContent |
| 10 | +import com.flipcash.services.user.UserManager |
| 11 | +import com.flipcash.shared.chat.ChatSummary |
| 12 | +import com.getcode.opencode.model.core.ID |
| 13 | +import com.getcode.opencode.model.financial.Token |
| 14 | +import com.getcode.solana.keys.Mint |
| 15 | +import com.getcode.util.resources.ResourceHelper |
| 16 | +import javax.inject.Inject |
| 17 | + |
| 18 | +/** |
| 19 | + * Builds the send-flow contact list from the raw inputs: device contacts, the |
| 20 | + * chat feed, the current search string, and known tokens. |
| 21 | + * |
| 22 | + * Pure and free of the ViewModel/flow machinery so it can be unit-tested |
| 23 | + * directly. Ephemeral overlays (e.g. typing indicators) are applied on top of |
| 24 | + * this output separately — see [SendFlowViewModel] — so they don't force a |
| 25 | + * re-filter/re-sort of the whole list. |
| 26 | + */ |
| 27 | +internal class ContactListBuilder @Inject constructor( |
| 28 | + private val userManager: UserManager, |
| 29 | + private val phoneUtils: PhoneUtils, |
| 30 | + private val resources: ResourceHelper, |
| 31 | +) { |
| 32 | + |
| 33 | + fun build( |
| 34 | + contactState: ContactState, |
| 35 | + searchString: String, |
| 36 | + chatFeed: List<ChatSummary>, |
| 37 | + tokensByMint: Map<Mint, Token>, |
| 38 | + ): List<ContactListItem> = buildList { |
| 39 | + val selfId = userManager.accountId |
| 40 | + val selfPhone = userManager.profile?.verifiedPhoneNumber |
| 41 | + |
| 42 | + val allContacts = contactState.contacts.values |
| 43 | + .filter { selfPhone == null || it.e164 != selfPhone } |
| 44 | + val filtered = if (searchString.isBlank()) { |
| 45 | + allContacts |
| 46 | + } else { |
| 47 | + allContacts.filter { |
| 48 | + it.displayName.contains(searchString, ignoreCase = true) || |
| 49 | + it.e164.contains(searchString, ignoreCase = true) |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + val dmChats = chatFeed.filter { it.metadata.type == ChatType.DM } |
| 54 | + // Build a reverse lookup: e164 -> chatId string for contacts with DMs |
| 55 | + val e164ToChatId = contactState.dmChatIds |
| 56 | + |
| 57 | + // Recents — driven by the chat feed, enriched with contact info |
| 58 | + val recentsE164s = mutableSetOf<String>() |
| 59 | + val recentRows = dmChats.mapNotNull { summary -> |
| 60 | + val chatId = summary.metadata.chatId |
| 61 | + val chatIdStr = chatId.toString() |
| 62 | + |
| 63 | + // Try to match this chat to a device contact |
| 64 | + val e164 = e164ToChatId.entries |
| 65 | + .firstOrNull { it.value == chatIdStr }?.key |
| 66 | + val deviceContact = e164 |
| 67 | + ?.takeIf { selfPhone == null || it != selfPhone } |
| 68 | + ?.let { contactState.contacts[it] } |
| 69 | + |
| 70 | + val contact = if (deviceContact != null) { |
| 71 | + if (searchString.isNotBlank() && |
| 72 | + !deviceContact.displayName.contains(searchString, ignoreCase = true) && |
| 73 | + !deviceContact.e164.contains(searchString, ignoreCase = true) |
| 74 | + ) { |
| 75 | + return@mapNotNull null |
| 76 | + } |
| 77 | + recentsE164s += deviceContact.e164 |
| 78 | + deviceContact |
| 79 | + } else { |
| 80 | + // Non-contact DM — build contact from chat member profile |
| 81 | + val isSelf = { member: ChatMember -> |
| 82 | + member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone) |
| 83 | + } |
| 84 | + val otherMember = summary.metadata.members |
| 85 | + .firstOrNull { !isSelf(it) } ?: return@mapNotNull null |
| 86 | + val phone = otherMember.userProfile.verifiedPhoneNumber |
| 87 | + val formattedPhone = phone?.let { phoneUtils.formatNumber(it) } |
| 88 | + val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() } |
| 89 | + ?: formattedPhone |
| 90 | + ?: return@mapNotNull null |
| 91 | + |
| 92 | + val unknown = DeviceContact.unknownContact( |
| 93 | + e164 = phone.orEmpty(), |
| 94 | + displayName = displayName, |
| 95 | + displayNumber = formattedPhone, |
| 96 | + ) |
| 97 | + if (searchString.isNotBlank() && |
| 98 | + !unknown.displayName.contains(searchString, ignoreCase = true) |
| 99 | + ) { |
| 100 | + return@mapNotNull null |
| 101 | + } |
| 102 | + if (unknown.e164.isNotEmpty()) { |
| 103 | + recentsE164s += unknown.e164 |
| 104 | + } |
| 105 | + unknown |
| 106 | + } |
| 107 | + |
| 108 | + ContactListItem.ContactRow( |
| 109 | + contact = contact, |
| 110 | + isOnFlipcash = true, |
| 111 | + lastActivity = summary.metadata.lastActivity, |
| 112 | + conversation = Conversation( |
| 113 | + chatId = chatId, |
| 114 | + lastMessagePreview = formatPreview(summary, selfId, tokensByMint), |
| 115 | + unreadCount = summary.unreadCount, |
| 116 | + ), |
| 117 | + ) |
| 118 | + } |
| 119 | + |
| 120 | + // On Flipcash — contacts that haven't chatted yet, use joinedAt as their sort timestamp |
| 121 | + val flipcashRows = filtered |
| 122 | + .filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s } |
| 123 | + .map { contact -> |
| 124 | + ContactListItem.ContactRow( |
| 125 | + contact = contact, |
| 126 | + isOnFlipcash = true, |
| 127 | + lastActivity = contactState.joinedAtByE164[contact.e164], |
| 128 | + ) |
| 129 | + } |
| 130 | + |
| 131 | + val flipcashCombined = (recentRows + flipcashRows).sortedWith( |
| 132 | + compareByDescending<ContactListItem.ContactRow> { it.lastActivity } |
| 133 | + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } |
| 134 | + ) |
| 135 | + |
| 136 | + val excludedE164s = recentsE164s + contactState.flipcashE164s |
| 137 | + val other = filtered |
| 138 | + .filter { it.e164 !in excludedE164s } |
| 139 | + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) |
| 140 | + |
| 141 | + addAll(flipcashCombined) |
| 142 | + if (other.isNotEmpty()) { |
| 143 | + add(ContactListItem.Header(resources.getString(R.string.title_nonFlipcashContacts))) |
| 144 | + other.forEach { add(ContactListItem.ContactRow(it, isOnFlipcash = false)) } |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + private fun formatPreview( |
| 149 | + summary: ChatSummary, |
| 150 | + selfId: ID?, |
| 151 | + tokensByMint: Map<Mint, Token>, |
| 152 | + ): String? { |
| 153 | + val lastMsg = summary.metadata.lastMessage ?: return null |
| 154 | + val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId |
| 155 | + return lastMsg.content.firstOrNull()?.let { content -> |
| 156 | + when (content) { |
| 157 | + is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() } |
| 158 | + is MessageContent.Cash -> { |
| 159 | + val formatted = content.amount.formatted() |
| 160 | + val name = content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } |
| 161 | + val label = if (name.isNotBlank()) { |
| 162 | + resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) |
| 163 | + } else { |
| 164 | + formatted |
| 165 | + } |
| 166 | + if (sentBySelf) { |
| 167 | + resources.getString(R.string.label_chat_preview_sentCash, label) |
| 168 | + } else { |
| 169 | + resources.getString(R.string.label_chat_preview_receivedCash, label) |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + // TODO: |
| 174 | + is MessageContent.Deleted -> null |
| 175 | + is MessageContent.Media -> null |
| 176 | + is MessageContent.Reply -> null |
| 177 | + is MessageContent.System -> null |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | +} |
0 commit comments