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 284d95362..2c705ebbb 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 @@ -290,6 +290,9 @@ internal class SendFlowViewModel @Inject constructor( !unknown.displayName.contains(searchString, ignoreCase = true)) { return@mapNotNull null } + if (unknown.e164.isNotEmpty()) { + recentsE164s += unknown.e164 + } unknown } @@ -301,18 +304,23 @@ internal class SendFlowViewModel @Inject constructor( chatId = chatId, lastActivity = summary.metadata.lastActivity, ) - }.sortedWith( - compareByDescending { it.lastActivity } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } - ) + } - // On Flipcash — contacts that haven't chatted yet + // 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 } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - .map { ContactListItem.ContactRow(contact = it, isOnFlipcash = true) } + .map { contact -> + ContactListItem.ContactRow( + contact = contact, + isOnFlipcash = true, + lastActivity = contactState.joinedAtByE164[contact.e164], + ) + } - val flipcashCombined = (recentRows + flipcashRows) + val flipcashCombined = (recentRows + flipcashRows).sortedWith( + compareByDescending { it.lastActivity } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.contact.displayName } + ) val excludedE164s = recentsE164s + contactState.flipcashE164s val other = filtered diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt index 20ecdb9da..005ffaa0b 100644 --- a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt @@ -142,4 +142,5 @@ private fun ResolvedUserFlags.editableEntries(): List> = listOf EditableEntry(Field.WithdrawalFeeAmount, withdrawalFeeAmount), EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool), EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard), + EditableEntry(Field.RequireCoinbaseEmailVerification, requireCoinbaseEmailVerification), ) \ No newline at end of file diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 5c3a6cf3a..ff03e2331 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -227,10 +227,7 @@ class EventStreamDelegate @Inject constructor( val lastMsg = if (resolvedMessages.isNotEmpty()) { trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) messageDataSource.upsert(chatId, resolvedMessages) - resolvedMessages.maxByOrNull { it.messageId }?.also { msg -> - metadataDataSource.updateLastMessageId(chatId, msg.messageId) - metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) - } + resolvedMessages.maxByOrNull { it.messageId } } else null // Advance event sequence cursor — gap-aware @@ -252,6 +249,9 @@ class EventStreamDelegate @Inject constructor( memberDataSource.updatePointers(chatId, pointer) } + // Process metadata updates before message-derived fields so that a + // FullRefresh (which replaces the entire entity) doesn't overwrite + // the lastActivity/lastMessageId set from the incoming message. for (metaUpdate in update.metadataUpdates) { when (metaUpdate) { is MetadataUpdate.FullRefresh -> { @@ -271,6 +271,14 @@ class EventStreamDelegate @Inject constructor( } } + // Update lastMessageId and lastActivity AFTER metadata updates so that + // incoming message timestamps always take precedence over a potentially + // stale FullRefresh. + lastMsg?.let { msg -> + metadataDataSource.updateLastMessageId(chatId, msg.messageId) + metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + } + // --- Process reaction updates --- if (update.reactionUpdates.isNotEmpty()) { diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index 888ccd5f8..aa29c6833 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.map +import kotlin.time.Instant import kotlin.collections.mapValues @@ -98,6 +99,7 @@ class ContactCoordinator @Inject constructor( val contacts: Map = emptyMap(), val flipcashE164s: Set = emptySet(), val dmChatIds: Map = emptyMap(), + val joinedAtByE164: Map = emptyMap(), val syncState: SyncState = SyncState.Idle, val hasEverSynced: Boolean = false, val hasDiscoveredFlipcashContacts: Boolean = false, @@ -370,12 +372,16 @@ class ContactCoordinator @Inject constructor( val dmChatIds = mappings .filter { it.dmChatId.isNotEmpty() } .associate { it.e164 to it.dmChatId } + val joinedAtByE164 = mappings + .filter { it.joinedAtEpochSeconds > 0 } + .associate { it.e164 to Instant.fromEpochSeconds(it.joinedAtEpochSeconds) } _state.update { it.copy( contacts = contacts, flipcashE164s = flipcashE164s, dmChatIds = dmChatIds, + joinedAtByE164 = joinedAtByE164, hasEverSynced = true, hasDiscoveredFlipcashContacts = hasDiscoveredFlipcashContacts, ) @@ -551,12 +557,21 @@ class ContactCoordinator @Inject constructor( } } val dmChatIds = mutableMapOf() + val joinedAts = mutableMapOf() entries.forEach { entry -> + contactDataSource.updateJoinedAt(entry.phoneNumber, entry.joinedAt.epochSeconds) + joinedAts[entry.phoneNumber] = entry.joinedAt val chatIdStr = entry.dmChatId?.toString() ?: return@forEach contactDataSource.updateDmChatId(entry.phoneNumber, chatIdStr) dmChatIds[entry.phoneNumber] = chatIdStr } - _state.update { it.copy(flipcashE164s = flipcashE164s, dmChatIds = it.dmChatIds + dmChatIds) } + _state.update { + it.copy( + flipcashE164s = flipcashE164s, + dmChatIds = it.dmChatIds + dmChatIds, + joinedAtByE164 = it.joinedAtByE164 + joinedAts, + ) + } trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process) }?.onFailure { error -> when (error) { diff --git a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts index 204116fba..10ca5044b 100644 --- a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts +++ b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.kotlinx.coroutines.play.services) implementation(project(":libs:messaging")) implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":apps:flipcash:shared:web")) api(project(":libs:network:coinbase:onramp")) implementation(project(":libs:network:jwt")) diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index ef5be1611..2b3c1b3af 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -29,6 +29,7 @@ import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError +import com.flipcash.app.userflags.UserFlagsCoordinator import com.getcode.utils.CodeServerError import com.getcode.utils.ErrorUtils import com.getcode.utils.NotifiableError @@ -54,8 +55,8 @@ import javax.inject.Inject sealed class PurchaseGate : Throwable() { data class WebViewWarning(val channel: WebViewChannel) : PurchaseGate() - data object GooglePayNotSupported : PurchaseGate() - data object GooglePayNoPaymentMethod : PurchaseGate() + class GooglePayNotSupported : PurchaseGate() + class GooglePayNoPaymentMethod : PurchaseGate() } typealias OrderWithPaymentLink = Pair @@ -76,6 +77,7 @@ class CoinbaseOnRampController @Inject constructor( private val transactionController: TransactionOperations, private val googlePayReadiness: GooglePayReadiness, private val webViewChannelDetector: WebViewChannelDetector, + private val userFlags: UserFlagsCoordinator, ) { private val _state = MutableStateFlow(CoinbaseOnRampState.Idle) @@ -115,8 +117,8 @@ class CoinbaseOnRampController @Inject constructor( suspend fun checkPurchaseGates(): Result { when (googlePayReadiness.check()) { - GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported) - GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod) + GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported()) + GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod()) GooglePayReadiness.Status.Ready -> Unit } @@ -231,8 +233,8 @@ class CoinbaseOnRampController @Inject constructor( val destination = destinationForToken(owner, token) - val email = userManager.profile?.verifiedEmailAddress val phone = userManager.profile?.verifiedPhoneNumber + val email = resolveEmail(phone) if (email == null || phone == null) { return Result.failure( @@ -276,8 +278,8 @@ class CoinbaseOnRampController @Inject constructor( val destination = destinationForToken(owner, token) - val email = userManager.profile?.verifiedEmailAddress val phone = userManager.profile?.verifiedPhoneNumber + val email = resolveEmail(phone) if (email == null || phone == null) { return Result.failure( @@ -339,6 +341,16 @@ class CoinbaseOnRampController @Inject constructor( ) } + private fun resolveEmail(phone: String?): String? { + val verified = userManager.profile?.verifiedEmailAddress + if (verified != null) return verified + + val requireEmail = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue + if (!requireEmail && phone != null) return "$phone@flipcash.com" + + return null + } + private fun destinationForToken(owner: AccountCluster, token: Token): String { return if (token.address == Mint.usdf) { owner.depositAddressFor(token).base58() @@ -379,9 +391,7 @@ class CoinbaseOnRampController @Inject constructor( ) when (error) { is GetJwtError.EmailVerificationRequired -> Result.failure( - OnRampAuthError.VerificationRequired( - email = true - ) + OnRampAuthError.VerificationRequired(email = true) ) is GetJwtError.PhoneVerificationRequired -> Result.failure( diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/22.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/22.json new file mode 100644 index 000000000..5229828ee --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/22.json @@ -0,0 +1,661 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "9ab78877c87a08d5bb3fd93908434346", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ab78877c87a08d5bb3fd93908434346')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index e9d40645c..028c7ba48 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -69,8 +69,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21), + AutoMigration(from = 21, to = 22), ], - version = 21, + version = 22, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt index 8f420168f..f2f67d22e 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt @@ -72,6 +72,9 @@ interface ContactDao { @Query("SELECT * FROM contact_mapping WHERE dmChatId = :dmChatId LIMIT 1") suspend fun getContactByDmChatId(dmChatId: String): ContactMappingEntity? + @Query("UPDATE contact_mapping SET joinedAtEpochSeconds = :epochSeconds WHERE e164 = :e164") + suspend fun updateJoinedAt(e164: String, epochSeconds: Long) + // endregion @Query("DELETE FROM contact_mapping") diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt index 82ad8bc25..ee22d270c 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt @@ -16,4 +16,6 @@ data class ContactMappingEntity( val displayNumber: String = "", @ColumnInfo(defaultValue = "") val dmChatId: String = "", + @ColumnInfo(defaultValue = "0") + val joinedAtEpochSeconds: Long = 0L, ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt index 6954f4d9e..091acf238 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt @@ -111,5 +111,9 @@ class ContactDataSource @Inject constructor( suspend fun getContactByDmChatId(dmChatId: String): ContactMappingEntity? = db?.contactDao()?.getContactByDmChatId(dmChatId) + suspend fun updateJoinedAt(e164: String, epochSeconds: Long) { + db?.contactDao()?.updateJoinedAt(e164, epochSeconds) + } + // endregion } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index eeb69fb9c..e145bfbe3 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -25,6 +25,7 @@ import com.flipcash.app.payments.PurchaseMethod import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.payments.PurchaseMethodMetadata import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.UserManager @@ -97,6 +98,7 @@ class SwapViewModel @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val coinbaseOnRampController: CoinbaseOnRampController, private val phantomWalletController: PhantomWalletController, + private val userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -798,7 +800,8 @@ class SwapViewModel @Inject constructor( val profile = userManager.profile val needsPhone = profile?.verifiedPhoneNumber == null - val needsEmail = profile?.verifiedEmailAddress == null + val requireEmail = userFlags.resolvedFlags.value.requireCoinbaseEmailVerification.effectiveValue + val needsEmail = requireEmail && profile?.verifiedEmailAddress == null if (needsPhone || needsEmail) { dispatchEvent(Event.OnVerificationNeeded(needsPhone, needsEmail)) return@onEach diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt index fdb6e4df8..e22258694 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -170,6 +171,17 @@ sealed class Field( ), ) + data object RequireCoinbaseEmailVerification : Field( + booleanPreferencesKey("override_require_coinbase_email_verification"), + encode = { it }, + decode = { it }, + label = R.string.label_flag_requireCoinbaseEmailVerification, + format = { if (it) "Yes" else "No" }, + editor = FieldEditor.SingleSelect( + options = listOf("Yes" to true, "No" to false) + ), + ) + data object MinimumHolderAmountForLeaderboard : Field( longPreferencesKey("override_min_holder_amount"), encode = { it.quarks }, diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt index e0242d185..ba4f1cf1c 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt @@ -33,6 +33,7 @@ data class ResolvedUserFlags( val usdcOnRampLiquidityPool: ResolvedFlag, val enablePhoneNumberSend: ResolvedFlag, val minimumHolderAmountForLeaderboard: ResolvedFlag, + val requireCoinbaseEmailVerification: ResolvedFlag, ) internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags( @@ -49,4 +50,5 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv usdcOnRampLiquidityPool = ResolvedFlag(preferredUsdcOnRampLiquidityPool, overrides.preferredUsdcOnRampLiquidityPool), enablePhoneNumberSend = ResolvedFlag(enablePhoneNumberSend, FieldOverride.None), minimumHolderAmountForLeaderboard = ResolvedFlag(minimumHolderValue, overrides.minimumHolderAmountForLeaderboard), + requireCoinbaseEmailVerification = ResolvedFlag(requireCoinbaseEmailVerification, overrides.requireCoinbaseEmailVerification), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index 0d28822da..dbde9bcd0 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -43,6 +43,7 @@ class UserFlagsCoordinator @Inject constructor( val withdrawalFeeAmount: FieldOverride, val preferredUsdcOnRampLiquidityPool: FieldOverride, val minimumHolderAmountForLeaderboard: FieldOverride, + val requireCoinbaseEmailVerification: FieldOverride, ) { companion object { val None = Overrides( @@ -55,6 +56,7 @@ class UserFlagsCoordinator @Inject constructor( withdrawalFeeAmount = FieldOverride.None, preferredUsdcOnRampLiquidityPool = FieldOverride.None, minimumHolderAmountForLeaderboard = FieldOverride.None, + requireCoinbaseEmailVerification = FieldOverride.None, ) } } @@ -89,6 +91,7 @@ class UserFlagsCoordinator @Inject constructor( withdrawalFeeAmount = prefs.readOverride(Field.WithdrawalFeeAmount), preferredUsdcOnRampLiquidityPool = prefs.readOverride(Field.PreferredUsdcOnRampLiquidityPool), minimumHolderAmountForLeaderboard = prefs.readOverride(Field.MinimumHolderAmountForLeaderboard), + requireCoinbaseEmailVerification = prefs.readOverride(Field.RequireCoinbaseEmailVerification), ) }.stateIn(scope, SharingStarted.Eagerly, Overrides.None) diff --git a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml index c512baba7..68f5861de 100644 --- a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Enter amount Preferred USDC On-Ramp Liquidity Pool Phone Number Send Enabled + Require Coinbase Email Verification Minimum Holder Amount for Leaderboard Enter amount \ No newline at end of file diff --git a/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto b/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto index 801393ca9..f6e15f3e9 100644 --- a/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto +++ b/definitions/flipcash/protos/src/main/proto/account/v1/flipcash_account_service.proto @@ -163,4 +163,7 @@ message UserFlags { // USDF amount, in quarks, that a user must hold to be counted as a holder on the leaderboard uint64 minimum_holder_value = 13; + + // Whether email verification is required for Coinbase purchase flows + bool require_coinbase_email_verification = 14; } diff --git a/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto index ad8b621f4..3d2853827 100644 --- a/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/contact/v1/model.proto @@ -4,6 +4,7 @@ package flipcash.contact.v1; import "common/v1/common.proto"; import "phone/v1/model.proto"; +import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/contact/v1;contactpb"; @@ -16,4 +17,7 @@ message FlipcashContact { // The DM chat ID for the Flipcash contact. If the chat doesn't exist, it needs // to be initiated with a cash send to initialize it common.v1.ChatId dm_chat_id = 2 [(validate.rules).message.required = true]; + + // Timestamp the contact joined Flipcash + google.protobuf.Timestamp join_ts = 3 [(validate.rules).timestamp.required = true]; } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index b87894bfd..015d94ec0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -5,6 +5,7 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.annotations.FlipcashManagedStreamingChannel import com.flipcash.services.internal.annotations.FlipcashProtocol import com.flipcash.services.internal.domain.ActivityFeedMessageMapper +import com.flipcash.services.internal.domain.ContactMapper import com.flipcash.services.internal.domain.ImageModerationResponseMapper import com.flipcash.services.internal.domain.UserFlagsMapper import com.flipcash.services.internal.domain.SocialAccountMapper @@ -163,7 +164,8 @@ internal object FlipcashModule { @Provides internal fun providesContactListRepository( service: ContactListService, - ): ContactListRepository = InternalContactListRepository(service) + contactMapper: ContactMapper, + ): ContactListRepository = InternalContactListRepository(service, contactMapper) @Provides internal fun providesResolverRepository( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ContactMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ContactMapper.kt new file mode 100644 index 000000000..13ebdb204 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ContactMapper.kt @@ -0,0 +1,21 @@ +package com.flipcash.services.internal.domain + +import com.codeinc.flipcash.gen.contact.v1.Model as ContactModel +import com.flipcash.services.internal.domain.mapper.Mapper +import com.flipcash.services.internal.network.extensions.toChatId +import com.flipcash.services.models.FlipcashContactEntry +import kotlin.time.Instant +import javax.inject.Inject + +internal class ContactMapper @Inject constructor() : + Mapper { + override fun map(from: ContactModel.FlipcashContact): FlipcashContactEntry { + return FlipcashContactEntry( + phoneNumber = from.phone.value, + dmChatId = from.dmChatId + .takeIf { !it.value.isEmpty } + ?.toChatId(), + joinedAt = Instant.fromEpochSeconds(from.joinTs.seconds, from.joinTs.nanos), + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt index 88874be84..1a5f0923d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt @@ -28,6 +28,7 @@ internal class UserFlagsMapper @Inject constructor(): preferredUsdcOnRampLiquidityPool = from.preferredOnRampUsdcLiquidityPool.toDomain(), enablePhoneNumberSend = from.enablePhoneNumberSend, minimumHolderValue = Fiat(quarks = from.minimumHolderValue), + requireCoinbaseEmailVerification = from.requireCoinbaseEmailVerification, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt index 465706367..eb531ef5c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ContactListService.kt @@ -1,10 +1,8 @@ package com.flipcash.services.internal.network.services import com.flipcash.services.models.ContactMethod -import com.flipcash.services.models.FlipcashContactEntry import com.flipcash.services.internal.network.api.ContactListApi import com.flipcash.services.internal.network.extensions.toChecksum -import com.flipcash.services.internal.network.extensions.toChatId import com.flipcash.services.models.CheckSyncError import com.flipcash.services.models.DeltaUploadError import com.flipcash.services.models.FullUploadError @@ -17,6 +15,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject import com.codeinc.flipcash.gen.contact.v1.ContactListService as RpcContactListService +import com.codeinc.flipcash.gen.contact.v1.Model as ContactModel internal class ContactListService @Inject constructor( private val api: ContactListApi, @@ -112,18 +111,11 @@ internal class ContactListService @Inject constructor( fun getContacts( owner: KeyPair, checksum: Checksum, - ): Flow>> { + ): Flow>> { return api.getFlipcashContacts(owner, checksum).map { response -> when (response.result) { RpcContactListService.GetFlipcashContactsResponse.Result.OK -> - Result.success(response.contactsList.map { proto -> - FlipcashContactEntry( - phoneNumber = proto.phone.value, - dmChatId = proto.dmChatId - .takeIf { !it.value.isEmpty } - ?.toChatId(), - ) - }) + Result.success(response.contactsList) RpcContactListService.GetFlipcashContactsResponse.Result.DENIED -> Result.failure(GetContactsError.Denied()) RpcContactListService.GetFlipcashContactsResponse.Result.NOT_FOUND -> diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt index 6f8373081..899f75f7a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalContactListRepository.kt @@ -1,5 +1,6 @@ package com.flipcash.services.internal.repositories +import com.flipcash.services.internal.domain.ContactMapper import com.flipcash.services.internal.network.services.ContactListService import com.flipcash.services.models.ContactMethod import com.flipcash.services.models.FlipcashContactEntry @@ -8,9 +9,11 @@ import com.getcode.ed25519.Ed25519 import com.getcode.solana.keys.Checksum import com.getcode.utils.ErrorUtils import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map internal class InternalContactListRepository( private val service: ContactListService, + private val contactMapper: ContactMapper, ) : ContactListRepository { override suspend fun checkSync( owner: Ed25519.KeyPair, @@ -38,4 +41,7 @@ internal class InternalContactListRepository( owner: Ed25519.KeyPair, checksum: Checksum, ): Flow>> = service.getContacts(owner, checksum) + .map { result -> + result.map { contacts -> contacts.map(contactMapper::map) } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt index d14936631..219089382 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/FlipcashContactEntry.kt @@ -1,8 +1,10 @@ package com.flipcash.services.models import com.flipcash.services.models.chat.ChatId +import kotlin.time.Instant data class FlipcashContactEntry( val phoneNumber: String, val dmChatId: ChatId?, + val joinedAt: Instant, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt index 2d19bd32b..642c6a1de 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt @@ -19,6 +19,7 @@ data class UserFlags( val preferredUsdcOnRampLiquidityPool: UsdcLiquidtyPool, val enablePhoneNumberSend: Boolean, val minimumHolderValue: Fiat, + val requireCoinbaseEmailVerification: Boolean, ) { companion object { val Default = UserFlags( @@ -35,6 +36,7 @@ data class UserFlags( preferredUsdcOnRampLiquidityPool = UsdcLiquidtyPool.Unknown, enablePhoneNumberSend = false, minimumHolderValue = Fiat.Zero, + requireCoinbaseEmailVerification = false, ) } } \ No newline at end of file