Skip to content

Commit 21ec9fc

Browse files
committed
feat(contacts): add join timestamp and sort feed by recency
- Add requireCoinbaseEmailVerification to UserFlags (editable override) - Add joinTs to FlipcashContact, persist to Room, and use in contact feed sorting so recently-joined contacts appear prominently while new messages still sort to the top - Extract ContactMapper for proto-to-domain contact mapping - Return raw proto list from ContactListService, map in repository Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 3256b98 commit 21ec9fc

25 files changed

Lines changed: 804 additions & 36 deletions

File tree

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ internal class SendFlowViewModel @Inject constructor(
290290
!unknown.displayName.contains(searchString, ignoreCase = true)) {
291291
return@mapNotNull null
292292
}
293+
if (unknown.e164.isNotEmpty()) {
294+
recentsE164s += unknown.e164
295+
}
293296
unknown
294297
}
295298

@@ -301,18 +304,23 @@ internal class SendFlowViewModel @Inject constructor(
301304
chatId = chatId,
302305
lastActivity = summary.metadata.lastActivity,
303306
)
304-
}.sortedWith(
305-
compareByDescending<ContactListItem.ContactRow> { it.lastActivity }
306-
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName }
307-
)
307+
}
308308

309-
// On Flipcash — contacts that haven't chatted yet
309+
// On Flipcash — contacts that haven't chatted yet, use joinedAt as their sort timestamp
310310
val flipcashRows = filtered
311311
.filter { it.e164 in contactState.flipcashE164s && it.e164 !in recentsE164s }
312-
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
313-
.map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) }
312+
.map { contact ->
313+
ContactListItem.ContactRow(
314+
contact = contact,
315+
isOnFlipcash = true,
316+
lastActivity = contactState.joinedAtByE164[contact.e164],
317+
)
318+
}
314319

315-
val flipcashCombined = (recentRows + flipcashRows)
320+
val flipcashCombined = (recentRows + flipcashRows).sortedWith(
321+
compareByDescending<ContactListItem.ContactRow> { it.lastActivity }
322+
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName }
323+
)
316324

317325
val excludedE164s = recentsE164s + contactState.flipcashE164s
318326
val other = filtered

apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,5 @@ private fun ResolvedUserFlags.editableEntries(): List<EditableEntry<*>> = listOf
142142
EditableEntry(Field.WithdrawalFeeAmount, withdrawalFeeAmount),
143143
EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool),
144144
EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard),
145+
EditableEntry(Field.RequireCoinbaseEmailVerification, requireCoinbaseEmailVerification),
145146
)

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,7 @@ class EventStreamDelegate @Inject constructor(
227227
val lastMsg = if (resolvedMessages.isNotEmpty()) {
228228
trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process)
229229
messageDataSource.upsert(chatId, resolvedMessages)
230-
resolvedMessages.maxByOrNull { it.messageId }?.also { msg ->
231-
metadataDataSource.updateLastMessageId(chatId, msg.messageId)
232-
metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds())
233-
}
230+
resolvedMessages.maxByOrNull { it.messageId }
234231
} else null
235232

236233
// Advance event sequence cursor — gap-aware
@@ -252,6 +249,9 @@ class EventStreamDelegate @Inject constructor(
252249
memberDataSource.updatePointers(chatId, pointer)
253250
}
254251

252+
// Process metadata updates before message-derived fields so that a
253+
// FullRefresh (which replaces the entire entity) doesn't overwrite
254+
// the lastActivity/lastMessageId set from the incoming message.
255255
for (metaUpdate in update.metadataUpdates) {
256256
when (metaUpdate) {
257257
is MetadataUpdate.FullRefresh -> {
@@ -271,6 +271,14 @@ class EventStreamDelegate @Inject constructor(
271271
}
272272
}
273273

274+
// Update lastMessageId and lastActivity AFTER metadata updates so that
275+
// incoming message timestamps always take precedence over a potentially
276+
// stale FullRefresh.
277+
lastMsg?.let { msg ->
278+
metadataDataSource.updateLastMessageId(chatId, msg.messageId)
279+
metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds())
280+
}
281+
274282
// --- Process reaction updates ---
275283

276284
if (update.reactionUpdates.isNotEmpty()) {

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import kotlinx.coroutines.launch
6161
import javax.inject.Inject
6262
import javax.inject.Singleton
6363
import kotlin.collections.map
64+
import kotlin.time.Instant
6465
import kotlin.collections.mapValues
6566

6667

@@ -98,6 +99,7 @@ class ContactCoordinator @Inject constructor(
9899
val contacts: Map<String, DeviceContact> = emptyMap(),
99100
val flipcashE164s: Set<String> = emptySet(),
100101
val dmChatIds: Map<String, String> = emptyMap(),
102+
val joinedAtByE164: Map<String, Instant> = emptyMap(),
101103
val syncState: SyncState = SyncState.Idle,
102104
val hasEverSynced: Boolean = false,
103105
val hasDiscoveredFlipcashContacts: Boolean = false,
@@ -370,12 +372,16 @@ class ContactCoordinator @Inject constructor(
370372
val dmChatIds = mappings
371373
.filter { it.dmChatId.isNotEmpty() }
372374
.associate { it.e164 to it.dmChatId }
375+
val joinedAtByE164 = mappings
376+
.filter { it.joinedAtEpochSeconds > 0 }
377+
.associate { it.e164 to Instant.fromEpochSeconds(it.joinedAtEpochSeconds) }
373378

374379
_state.update {
375380
it.copy(
376381
contacts = contacts,
377382
flipcashE164s = flipcashE164s,
378383
dmChatIds = dmChatIds,
384+
joinedAtByE164 = joinedAtByE164,
379385
hasEverSynced = true,
380386
hasDiscoveredFlipcashContacts = hasDiscoveredFlipcashContacts,
381387
)
@@ -551,12 +557,21 @@ class ContactCoordinator @Inject constructor(
551557
}
552558
}
553559
val dmChatIds = mutableMapOf<String, String>()
560+
val joinedAts = mutableMapOf<String, Instant>()
554561
entries.forEach { entry ->
562+
contactDataSource.updateJoinedAt(entry.phoneNumber, entry.joinedAt.epochSeconds)
563+
joinedAts[entry.phoneNumber] = entry.joinedAt
555564
val chatIdStr = entry.dmChatId?.toString() ?: return@forEach
556565
contactDataSource.updateDmChatId(entry.phoneNumber, chatIdStr)
557566
dmChatIds[entry.phoneNumber] = chatIdStr
558567
}
559-
_state.update { it.copy(flipcashE164s = flipcashE164s, dmChatIds = it.dmChatIds + dmChatIds) }
568+
_state.update {
569+
it.copy(
570+
flipcashE164s = flipcashE164s,
571+
dmChatIds = it.dmChatIds + dmChatIds,
572+
joinedAtByE164 = it.joinedAtByE164 + joinedAts,
573+
)
574+
}
560575
trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process)
561576
}?.onFailure { error ->
562577
when (error) {

apps/flipcash/shared/onramp/coinbase/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies {
2323
implementation(libs.kotlinx.coroutines.play.services)
2424
implementation(project(":libs:messaging"))
2525
implementation(project(":apps:flipcash:shared:featureflags"))
26+
implementation(project(":apps:flipcash:shared:userflags"))
2627
implementation(project(":apps:flipcash:shared:web"))
2728
api(project(":libs:network:coinbase:onramp"))
2829
implementation(project(":libs:network:jwt"))

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.getcode.opencode.model.transactions.SwapFundingSource
2929
import com.getcode.solana.keys.Mint
3030
import com.getcode.solana.keys.base58
3131
import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError
32+
import com.flipcash.app.userflags.UserFlagsCoordinator
3233
import com.getcode.utils.CodeServerError
3334
import com.getcode.utils.ErrorUtils
3435
import com.getcode.utils.NotifiableError
@@ -54,8 +55,8 @@ import javax.inject.Inject
5455

5556
sealed class PurchaseGate : Throwable() {
5657
data class WebViewWarning(val channel: WebViewChannel) : PurchaseGate()
57-
data object GooglePayNotSupported : PurchaseGate()
58-
data object GooglePayNoPaymentMethod : PurchaseGate()
58+
class GooglePayNotSupported : PurchaseGate()
59+
class GooglePayNoPaymentMethod : PurchaseGate()
5960
}
6061

6162
typealias OrderWithPaymentLink = Pair<String, OnRampPurchaseResponse.PaymentLink>
@@ -76,6 +77,7 @@ class CoinbaseOnRampController @Inject constructor(
7677
private val transactionController: TransactionOperations,
7778
private val googlePayReadiness: GooglePayReadiness,
7879
private val webViewChannelDetector: WebViewChannelDetector,
80+
private val userFlags: UserFlagsCoordinator,
7981
) {
8082

8183
private val _state = MutableStateFlow<CoinbaseOnRampState>(CoinbaseOnRampState.Idle)
@@ -115,8 +117,8 @@ class CoinbaseOnRampController @Inject constructor(
115117

116118
suspend fun checkPurchaseGates(): Result<Unit> {
117119
when (googlePayReadiness.check()) {
118-
GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported)
119-
GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod)
120+
GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported())
121+
GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod())
120122
GooglePayReadiness.Status.Ready -> Unit
121123
}
122124

@@ -231,8 +233,8 @@ class CoinbaseOnRampController @Inject constructor(
231233

232234
val destination = destinationForToken(owner, token)
233235

234-
val email = userManager.profile?.verifiedEmailAddress
235236
val phone = userManager.profile?.verifiedPhoneNumber
237+
val email = resolveEmail(phone)
236238

237239
if (email == null || phone == null) {
238240
return Result.failure(
@@ -276,8 +278,8 @@ class CoinbaseOnRampController @Inject constructor(
276278

277279
val destination = destinationForToken(owner, token)
278280

279-
val email = userManager.profile?.verifiedEmailAddress
280281
val phone = userManager.profile?.verifiedPhoneNumber
282+
val email = resolveEmail(phone)
281283

282284
if (email == null || phone == null) {
283285
return Result.failure(
@@ -339,6 +341,16 @@ class CoinbaseOnRampController @Inject constructor(
339341
)
340342
}
341343

344+
private fun resolveEmail(phone: String?): String? {
345+
val verified = userManager.profile?.verifiedEmailAddress
346+
if (verified != null) return verified
347+
348+
val requireEmail = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue
349+
if (!requireEmail && phone != null) return "$phone@flipcash.com"
350+
351+
return null
352+
}
353+
342354
private fun destinationForToken(owner: AccountCluster, token: Token): String {
343355
return if (token.address == Mint.usdf) {
344356
owner.depositAddressFor(token).base58()
@@ -379,9 +391,7 @@ class CoinbaseOnRampController @Inject constructor(
379391
)
380392
when (error) {
381393
is GetJwtError.EmailVerificationRequired -> Result.failure(
382-
OnRampAuthError.VerificationRequired(
383-
email = true
384-
)
394+
OnRampAuthError.VerificationRequired(email = true)
385395
)
386396

387397
is GetJwtError.PhoneVerificationRequired -> Result.failure(

0 commit comments

Comments
 (0)