Skip to content

Commit 4e4ffe2

Browse files
committed
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 <git@bmcreations.dev>
1 parent 06d0f55 commit 4e4ffe2

6 files changed

Lines changed: 259 additions & 172 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@
827827
<string name="label_chat_preview_cash_suffix">%1$s of %2$s</string>
828828
<string name="label_chat_preview_sentCash">You sent %1$s</string>
829829
<string name="label_chat_preview_receivedCash">You received %1$s</string>
830+
<string name="label_isTyping">Is Typing…</string>
830831

831832
<string name="label_unknownContact">Unknown Contact</string>
832833

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
}

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,30 @@ import kotlin.time.Instant
66

77
internal sealed interface ContactListItem {
88
data class Header(val title: String) : ContactListItem
9+
10+
/**
11+
* A single contact in the send list.
12+
*
13+
* [lastActivity] is the sort key and is present for any row that should be
14+
* ranked by recency — a chat's last activity for recents, or the contact's
15+
* joinedAt for on-Flipcash contacts without a chat yet — so it lives on the
16+
* row rather than inside [conversation].
17+
*
18+
* [conversation] holds everything derived from an existing DM and is `null`
19+
* for contacts with no chat (on-Flipcash-but-never-messaged, or off-Flipcash).
20+
*/
921
data class ContactRow(
1022
val contact: DeviceContact,
1123
val isOnFlipcash: Boolean,
12-
val lastMessagePreview: String? = null,
13-
val unreadCount: Int = 0,
14-
val chatId: ChatId? = null,
1524
val lastActivity: Instant? = null,
25+
val conversation: Conversation? = null,
1626
) : ContactListItem
1727
}
28+
29+
/** Presentation state derived from an existing DM with a contact. */
30+
internal data class Conversation(
31+
val chatId: ChatId,
32+
val lastMessagePreview: String? = null,
33+
val unreadCount: Int = 0,
34+
val isTyping: Boolean = false,
35+
)

0 commit comments

Comments
 (0)