diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index e90780bce..51f788695 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -20,6 +20,8 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class Chat(val identifier: ChatIdentifier): DeeplinkType, Navigatable + @Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable + @Serializable data class Tipcard(val userId: ID): DeeplinkType @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt index 26ed1d31c..49bb0b6ba 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt @@ -20,5 +20,6 @@ object Linkify { fun tokenInfo(token: Token): String = tokenInfo(token.address) fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}" fun chatById(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" + fun tipChatById(chatId: ChatId): String = "https://app.flipcash.com/tip/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" fun chatByPhone(phoneNumber: String): String = "https://app.flipcash.com/chat/${phoneNumber.urlEncode()}" } \ No newline at end of file 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 index 2129ce76e..116815955 100644 --- 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 @@ -6,11 +6,9 @@ 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.flipcash.shared.chat.ui.ConversationReference -import com.getcode.opencode.model.core.ID +import com.flipcash.shared.chat.ui.toConversationReference import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper @@ -110,11 +108,7 @@ internal class ContactListBuilder @Inject constructor( contact = contact, isOnFlipcash = true, lastActivity = summary.metadata.lastActivity, - conversation = ConversationReference( - chatId = chatId, - lastMessagePreview = formatPreview(summary, selfId, tokensByMint), - unreadCount = summary.unreadCount, - ), + conversation = summary.toConversationReference(selfId, tokensByMint, resources), ) } @@ -145,45 +139,4 @@ internal class ContactListBuilder @Inject constructor( 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 -> { - val message = content.text.takeIf { it.isNotEmpty() } ?: return null - if (sentBySelf) { - resources.getString(R.string.label_chat_preview_sentMessage, message) - } else { - message - } - } - 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/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index f3d07f6fb..aae6070ac 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":libs:vibrator:bindings")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt new file mode 100644 index 000000000..4f831f324 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatParticipant.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID + +/** + * The counterparty a DM header and info card renders. + * + * A conversation is backed by one of two identity sources depending on its + * [com.flipcash.services.models.chat.ChatType]: + * + * - [Contact] — a `CONTACT_DM`. Identity comes from a device [DeviceContact]: it has a phone + * number and supports the "add to contacts" action. + * - [TipUser] — a `TIP_DM`. The counterparty has no device contact; identity comes from their + * server [UserProfile] (display name + profile picture), the same source the tips list uses. + */ +internal sealed interface ChatParticipant { + val displayName: String + + data class Contact(val contact: DeviceContact) : ChatParticipant { + override val displayName: String get() = contact.displayName + } + + data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant { + override val displayName: String get() = profile.displayName.orEmpty() + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index e41021ef6..5a9e2058b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -23,7 +23,7 @@ import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.messenger.R -import com.flipcash.services.models.buildDmPaymentMetadata +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent @@ -34,6 +34,9 @@ import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.opencode.model.core.ID import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController @@ -44,7 +47,6 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace import com.getcode.view.BaseViewModel @@ -58,6 +60,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -85,6 +88,8 @@ data class TypingConstraints( internal class ChatViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, private val contactCoordinator: ContactCoordinator, + private val contactPaymentDelegate: ContactPaymentDelegate, + private val tipPaymentDelegate: TipPaymentDelegate, private val transactionController: TransactionController, private val tokenCoordinator: TokenCoordinator, private val exchange: Exchange, @@ -101,14 +106,17 @@ internal class ChatViewModel @Inject constructor( sealed interface ResolveState { data object Pending : ResolveState - data class Resolved(val authority: PublicKey) : ResolveState + // The counterparty resolved to an on-chain address, so the send can proceed. The address + // itself isn't held here — the payment delegates re-resolve it at send time (a cache hit), + // keyed by the counterparty's phone number or user id. + data object Resolved : ResolveState data object Failed : ResolveState } data class State( val separatorConfig: SeparatorConfig= SeparatorConfig.Continuous(), val chatId: ChatId? = null, - val chattingWith: DeviceContact? = null, + val participant: ChatParticipant? = null, val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), val resolveState: ResolveState = ResolveState.Pending, @@ -124,6 +132,7 @@ internal class ChatViewModel @Inject constructor( sealed interface Event { data class OnChatOpened(val identifier: ChatIdentifier) : Event data class OnContactFound(val contact: DeviceContact): Event + data class OnTipUserResolved(val userId: ID, val profile: UserProfile): Event data class OnCurrencySymbolUpdated(val symbol: String): Event data object RefreshContact : Event data class ChatFound(val chatId: ChatId) : Event @@ -131,20 +140,19 @@ internal class ChatViewModel @Inject constructor( data object OnStartMessageInput: Event data object OnStopMessageInput: Event data class TypistsUpdated(val typists: Set) : Event - data class ResolveCompleted(val authority: PublicKey) : Event + data object ResolveCompleted : Event data object ResolveFailed : Event data object SendMessage : Event data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event - data class NavigateToAmountEntry(val contact: DeviceContact) : Event + data object NavigateToAmountEntry : Event data object PresentDepositOptions : Event data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event data object OnConfirmRequested : Event data class OnSendRequested( val amount: Fiat, val token: Token, - val destinationOwner: PublicKey, ) : Event data class SendStateUpdated( val loading: Boolean = false, @@ -225,19 +233,45 @@ internal class ChatViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } + // The amount entry adapts to the chat type: a tip DM swipes to *tip* and enforces the minimum + // tip (from the tip payment delegate); a contact DM swipes to *send* with no minimum. + private fun amountStyle(isTip: Boolean) = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain( + resources.getString(if (isTip) R.string.action_swipeToTip else R.string.action_swipeToSend) + ), + actionStyle = ConfirmationStyle.Slide, + infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, + overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, + belowMinHint = if (isTip) { + { min -> resources.getString(R.string.subtitle_tipHintMinimum, min) } + } else null, + ) + + private val isTipFlow = stateFlow + .map { it.participant is ChatParticipant.TipUser } + .distinctUntilChanged() + + private val amountStyleFlow by lazy { + isTipFlow + .map { amountStyle(isTip = it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), amountStyle(isTip = false)) + } + + private val minAmountFlow by lazy { + combine(isTipFlow, tipPaymentDelegate.minTipAmount) { isTip, tipMin -> + if (isTip) tipMin else null + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + } + val amountDelegate by lazy { AmountEntryDelegate( exchange = exchange, scope = viewModelScope, - style = AmountEntryStyle( - actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_swipeToSend)), - actionStyle = ConfirmationStyle.Slide, - infoHint = { resources.getString(R.string.subtitle_sendHint, it) }, - overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) }, - ), + style = amountStyleFlow, loadingState = stateFlow.map { it.sendProgress } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()), maxAmount = maxAmountFlow, + minimumAmount = minAmountFlow, ) } @@ -299,6 +333,13 @@ internal class ChatViewModel @Inject constructor( ) if (contact != null) { dispatchEvent(Event.OnContactFound(contact)) + } else { + // No device contact backs this chat — it's a tip DM. Warm the member + // store (fetch + persist if nothing is cached) so the reactive + // tip-identity collector can resolve the counterparty from their server + // profile. Identity is set reactively (see initChatHandlers), not here, + // so it can't be missed by a fast tap on "Send $". + viewModelScope.launch { chatCoordinator.getOtherMember(identifier.chatId) } } } } @@ -311,7 +352,7 @@ internal class ChatViewModel @Inject constructor( .onEach { event -> viewModelScope.launch { contactCoordinator.resolve(event.contact.e164) - .onSuccess { dispatchEvent(Event.ResolveCompleted(it)) } + .onSuccess { dispatchEvent(Event.ResolveCompleted) } .onFailure { dispatchEvent(Event.ResolveFailed) } } }.launchIn(viewModelScope) @@ -319,7 +360,7 @@ internal class ChatViewModel @Inject constructor( // Re-resolve the contact from the device (e.g. after adding via system contacts) eventFlow .filterIsInstance() - .mapNotNull { stateFlow.value.chattingWith?.e164 } + .mapNotNull { (stateFlow.value.participant as? ChatParticipant.Contact)?.contact?.e164 } .onEach { e164 -> viewModelScope.launch { val refreshed = contactCoordinator.refreshContact(e164) @@ -330,6 +371,19 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + // Resolve the tip counterparty reactively from the chat members. Tip DMs have no device + // contact, so identity (name + avatar + user id) comes from the other member's server + // profile — the same source the tips list uses. Reactive so it settles as soon as the + // members are available and can't be missed by the send gate. Never clobbers a device + // contact: the OnTipUserResolved reducer keeps an existing Contact participant. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMembers(it) } + .mapNotNull { members -> members.firstOrNull { it.userId != userManager.accountId } } + .distinctUntilChanged() + .onEach { member -> dispatchEvent(Event.OnTipUserResolved(member.userId, member.userProfile)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. stateFlow.mapNotNull { it.chatId } @@ -525,8 +579,10 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) eventFlow.filterIsInstance() - .mapNotNull { stateFlow.value.chattingWith } - .onEach { contact -> + // Both contact DMs and tip DMs can send cash; the recipient is whichever participant + // backs the chat. The final send branches on that type (see Event.OnSendRequested). + .filter { stateFlow.value.participant != null } + .onEach { val addMoney = featureFlags.get(FeatureFlag.AddMoneyUX) if (!tokenCoordinator.hasGiveableBalance()) { if (!tokenCoordinator.hasBalance()) { @@ -537,7 +593,7 @@ internal class ChatViewModel @Inject constructor( return@onEach } amountDelegate.reset() - dispatchEvent(Event.NavigateToAmountEntry(contact)) + dispatchEvent(Event.NavigateToAmountEntry) }.launchIn(viewModelScope) eventFlow @@ -549,9 +605,12 @@ internal class ChatViewModel @Inject constructor( } }.launchIn(viewModelScope) - // Send cash + // Send cash. The transfer itself is delegated by chat type: a contact DM pays a phone + // number (contact metadata), a tip DM pays a user id (tip metadata). Both delegates resolve + // the recipient, transfer, debit the local balance, and sync the feed; this handler owns the + // shared amount verification, send state, analytics, and error UI. eventFlow.filterIsInstance() - .onEach { (amount, token, destination) -> + .onEach { (amount, token) -> viewModelScope.launch { val owner = userManager.accountCluster ?: return@launch val rate = exchange.preferredRate @@ -583,34 +642,29 @@ internal class ChatViewModel @Inject constructor( return@launch } - val appMetadataBytes = buildDmPaymentMetadata( - chatId = stateFlow.value.chatId, - sourcePhone = contactCoordinator.selfPhone, - destinationPhone = stateFlow.value.chattingWith?.e164, - ) + val chatId = stateFlow.value.chatId + val result = when (val participant = stateFlow.value.participant) { + is ChatParticipant.Contact -> contactPaymentDelegate.send( + contact = participant.contact, + chatId = chatId, + verifiedFiat = verifiedFiat, + token = token, + source = source, + ) + is ChatParticipant.TipUser -> tipPaymentDelegate.send( + userId = participant.userId, + verifiedFiat = verifiedFiat, + token = token, + source = source, + ) + null -> { + dispatchEvent(Event.SendStateUpdated()) + return@launch + } + } - transactionController.directTransfer( - amount = verifiedFiat, - token = token, - source = source, - destinationOwner = destination, - appMetadata = appMetadataBytes, - ).fold( - onSuccess = { - tokenCoordinator.subtract(token, verifiedFiat.localFiat) - Result.success(verifiedFiat) - }, - onFailure = { Result.failure(it) } - ).onSuccess { amount -> + result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) - val chatId = stateFlow.value.chatId - if (chatId != null) { - chatCoordinator.loadMessages(chatId) - } else { - // New conversation — server just created the DM chat. - // Sync the feed so it appears in the contact list. - chatCoordinator.refreshFeed() - } delay(400.milliseconds) analytics.transfer( event = Analytics.Transfer.SentCash, @@ -619,7 +673,7 @@ internal class ChatViewModel @Inject constructor( ) dispatchEvent( Dispatchers.Main, - Event.SendComplete(amount.localFiat.nativeAmount) + Event.SendComplete(verifiedFiat.localFiat.nativeAmount) ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) @@ -684,12 +738,10 @@ internal class ChatViewModel @Inject constructor( val rate = exchange.preferredRate val amount = Fiat(enteredAmount, rate.currency) - val resolve = stateFlow.value.resolveState - if (resolve is ResolveState.Resolved) { + if (stateFlow.value.resolveState is ResolveState.Resolved) { dispatchEvent(Event.OnSendRequested( amount = amount, token = token, - destinationOwner = resolve.authority, )) } } @@ -743,13 +795,22 @@ internal class ChatViewModel @Inject constructor( when (event) { is Event.OnChatOpened -> { state -> when (val id = event.identifier) { - is ChatIdentifier.ByContact -> state.copy(chattingWith = id.contact) + is ChatIdentifier.ByContact -> state.copy(participant = ChatParticipant.Contact(id.contact)) is ChatIdentifier.ByChatId -> state } } is Event.OnContactFound -> { state -> - state.copy( - chattingWith = event.contact + state.copy(participant = ChatParticipant.Contact(event.contact)) + } + is Event.OnTipUserResolved -> { state -> + // A device contact, once matched, wins over the server profile (it carries the + // phone number and the user's own naming). Otherwise this is a tip DM: adopt the + // profile identity and mark the recipient resolved so the send can proceed (the + // tip user is known to exist; the tip send resolves their address at send time). + if (state.participant is ChatParticipant.Contact) state + else state.copy( + participant = ChatParticipant.TipUser(event.userId, event.profile), + resolveState = ResolveState.Resolved, ) } is Event.OnCurrencySymbolUpdated -> { state -> state.copy(cashSymbol = event.symbol) } @@ -759,15 +820,15 @@ internal class ChatViewModel @Inject constructor( Event.OnStartMessageInput -> { state -> state } Event.OnStopMessageInput -> { state -> state } is Event.TypistsUpdated -> { state -> state.copy(typists = event.typists) } - is Event.ResolveCompleted -> { state -> - state.copy(resolveState = ResolveState.Resolved(event.authority)) + Event.ResolveCompleted -> { state -> + state.copy(resolveState = ResolveState.Resolved) } is Event.ResolveFailed -> { state -> state.copy(resolveState = ResolveState.Failed) } is Event.SendMessage -> { state -> state } is Event.RetryMessage -> { state -> state } - is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } + Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.OnConfirmRequested -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index bb8ad15f9..6413182fe 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -32,7 +32,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val keyboard = rememberKeyboardController() ChatInputScaffold( - topBar = { ChatTopBar(navigator, state.chattingWith) }, + topBar = { ChatTopBar(navigator, state.participant) }, bottomBar = { UserControlBottomBar( state = state, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 1b14d8941..216ec106c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.messenger.internal.ChatParticipant import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme @@ -31,7 +31,7 @@ import com.getcode.ui.core.measured @Composable internal fun ChatTopBar( navigator: CodeNavigator, - chattingWith: DeviceContact?, + participant: ChatParticipant?, ) { var titleHeight by remember { mutableStateOf(0.dp) } val bgColor = CodeTheme.colors.background @@ -60,8 +60,8 @@ internal fun ChatTopBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - ContactAvatar( - contact = chattingWith, + ParticipantAvatar( + participant = participant, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), @@ -69,7 +69,7 @@ internal fun ChatTopBar( Text( modifier = Modifier.weight(1f), - text = chattingWith?.displayName.orEmpty(), + text = participant?.displayName.orEmpty(), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 8c6f6772a..041e56664 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -29,16 +29,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.messenger.internal.ChatParticipant import com.flipcash.features.messenger.R -import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @Composable internal fun ContactInfoContainer( - contact: DeviceContact?, + participant: ChatParticipant?, modifier: Modifier = Modifier, onRefreshContact: () -> Unit = {}, ) { + // Phone number and the add-to-contacts pill only apply to a device contact; a tip DM's + // counterparty (a server profile) has neither. + val contact = (participant as? ChatParticipant.Contact)?.contact Column( modifier = modifier .border( @@ -49,15 +52,15 @@ internal fun ContactInfoContainer( .padding(CodeTheme.dimens.grid.x6), horizontalAlignment = Alignment.CenterHorizontally, ) { - ContactAvatar( - contact = contact, + ParticipantAvatar( + participant = participant, modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), ) Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), - text = contact?.displayName.orEmpty(), + text = participant?.displayName.orEmpty(), autoSize = TextAutoSize.StepBased( minFontSize = CodeTheme.typography.textSmall.fontSize, maxFontSize = CodeTheme.typography.textLarge.fontSize, @@ -68,7 +71,7 @@ internal fun ContactInfoContainer( color = CodeTheme.colors.textMain, ) - if (contact?.isUnknown == false) { + if (contact != null && !contact.isUnknown) { Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), text = contact.displayNumber, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 217d3a740..c997cfdf1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -267,7 +267,7 @@ internal fun MessageList( contentAlignment = Alignment.Center, ) { ContactInfoContainer( - contact = state.chattingWith, + participant = state.participant, modifier = Modifier .padding(horizontal = CodeTheme.dimens.grid.x12), onRefreshContact = { onAction(ChatAction.RefreshContact) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt new file mode 100644 index 000000000..c328a2427 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipcash.app.messenger.internal.ChatParticipant +import com.flipcash.shared.common.ui.ContactAvatar + +/** + * Renders a [ChatParticipant]'s avatar, selecting the identity source by chat type: a device + * contact's photo/initials for a [ChatParticipant.Contact], the server profile picture for a + * [ChatParticipant.TipUser]. A null participant falls through to the unknown-contact avatar. + * + * ContactAvatar sizes the profile-picture rendition to the avatar's measured bounds, so both the + * small top-bar avatar and the large info card get an appropriately crisp image. + */ +@Composable +internal fun ParticipantAvatar( + participant: ChatParticipant?, + modifier: Modifier = Modifier, +) { + when (participant) { + is ChatParticipant.Contact -> ContactAvatar(contact = participant.contact, modifier = modifier) + is ChatParticipant.TipUser -> ContactAvatar(userProfile = participant.profile, modifier = modifier) + null -> ContactAvatar(contact = null, modifier = modifier) + } +} diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index 55bf64914..c32bde610 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -16,4 +16,5 @@ dependencies { implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) + implementation(project(":apps:flipcash:shared:tokens")) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt index 5f7f9d8ae..d2c8e7adb 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -7,13 +7,14 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable +import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager -import com.getcode.opencode.model.core.ID import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -29,7 +30,9 @@ internal class TipFlowViewModel @Inject constructor( chatCoordinator: ChatCoordinator, userManager: UserManager, tippingCoordinator: TippingCoordinator, + tokenCoordinator: TokenCoordinator, shareable: ShareSheetController, + private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -84,12 +87,14 @@ internal class TipFlowViewModel @Inject constructor( .onResult(onSuccess = { card -> dispatchEvent(Event.OnTipCardPopulated(card)) }) .launchIn(viewModelScope) - chatCoordinator.feed(ChatType.TIP_DM) - .onEach { summaries -> - val selfId = userManager.accountId - dispatchEvent(Event.ChatsUpdated(summaries.map { it.toPreview(selfId) })) - } - .launchIn(viewModelScope) + combine( + chatCoordinator.feed(ChatType.TIP_DM), + tokenCoordinator.tokens, + ) { summaries, tokens -> + val selfId = userManager.accountId + val tokensByMint = tokens.associateBy { it.address } + summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } + }.onEach { dispatchEvent(Event.ChatsUpdated(it)) }.launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -98,19 +103,6 @@ internal class TipFlowViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun ChatSummary.toPreview(selfId: ID?): ConversationReference { - // A tip DM has no separate contact, so carry the counterparty's identity (name + avatar) - // straight from the chat member that isn't us. - val other = metadata.members.firstOrNull { it.userId != selfId } - return ConversationReference( - chatId = metadata.chatId, - displayName = other?.userProfile?.displayName, - image = other?.userProfile?.profilePicture, - lastMessagePreview = null, // TODO: - unreadCount = unreadCount, - ) - } - companion object { private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt index ddd222474..f712e88c2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt @@ -24,7 +24,6 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.tipping.internal.TipFlowViewModel import com.flipcash.features.tipping.R -import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing @@ -106,11 +105,11 @@ private fun TipChatRow( modifier = modifier, avatar = { ContactAvatar( - photoUri = chat.image?.url(preferred = MediaItemRendition.Role.THUMBNAIL), + image = chat.image, + displayName = chat.displayName.orEmpty(), modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), - displayName = chat.displayName.orEmpty(), ) }, title = { @@ -122,7 +121,7 @@ private fun TipChatRow( ) ChatRowTrailing( - lastActivity = null, + lastActivity = chat.lastActivity, unreadCount = chat.unreadCount, canOpen = true, ) diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index e8b0a698c..4bf52372d 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:core")) + implementation(project(":apps:flipcash:shared:chat")) implementation(project(":ui:core")) implementation(project(":ui:components")) implementation(project(":ui:theme")) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt new file mode 100644 index 000000000..1eafa76ee --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -0,0 +1,76 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.core.R +import com.flipcash.services.models.chat.MessageContent +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 + +/** + * Maps a [ChatSummary] to the presentation [ConversationReference] shared by every + * conversation-style list (send contacts, tip DMs, …): counterparty identity, a + * formatted last-message preview, the last-activity timestamp, and unread count. + * + * The counterparty ([ConversationReference.displayName]/[ConversationReference.image]) + * is taken from the chat member that isn't [selfId] — used directly by rows with no + * separate contact (e.g. tip DMs); the send flow ignores those in favour of its matched + * device contact. + */ +fun ChatSummary.toConversationReference( + selfId: ID?, + tokensByMint: Map, + resources: ResourceHelper, +): ConversationReference { + val other = metadata.members.firstOrNull { it.userId != selfId } + return ConversationReference( + chatId = metadata.chatId, + displayName = other?.userProfile?.displayName, + image = other?.userProfile?.profilePicture, + lastMessagePreview = formatPreview(selfId, tokensByMint, resources), + lastActivity = metadata.lastActivity, + unreadCount = unreadCount, + ) +} + +private fun ChatSummary.formatPreview( + selfId: ID?, + tokensByMint: Map, + resources: ResourceHelper, +): String? { + val lastMsg = metadata.lastMessage ?: return null + val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId + return lastMsg.content.firstOrNull()?.let { content -> + when (content) { + is MessageContent.Text -> { + val message = content.text.takeIf { it.isNotEmpty() } ?: return null + if (sentBySelf) { + resources.getString(R.string.label_chat_preview_sentMessage, message) + } else { + message + } + } + 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/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 99660b8ac..928717b37 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import kotlin.time.Instant /** Presentation state derived from an existing DM with a contact. */ data class ConversationReference( @@ -11,6 +12,8 @@ data class ConversationReference( /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ val image: MediaItem? = null, val lastMessagePreview: String? = null, + /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ + val lastActivity: Instant? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, ) \ No newline at end of file diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 0180792aa..9ea8950ae 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -35,7 +35,9 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.services.models.PushChatMetadata import com.flipcash.services.models.Substitution +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.notifications.R import com.getcode.utils.TraceType @@ -197,7 +199,7 @@ class NotificationService : FirebaseMessagingService(), .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) .setAutoCancel(true) - .setContentIntent(buildContentIntent(payload?.navigation)) + .setContentIntent(buildContentIntent(payload?.chatMetadata, payload?.navigation)) .apply { if (groupKey != null) setGroup(groupKey) } @@ -410,14 +412,21 @@ class NotificationService : FirebaseMessagingService(), ).build() } - internal fun Context.buildContentIntent(navigation: NavigationTrigger?): PendingIntent { + internal fun Context.buildContentIntent( + metadata: PushChatMetadata?, + navigation: NavigationTrigger?, + ): PendingIntent { val target = when (navigation) { is NavigationTrigger.CurrencyInfo -> Intent(Intent.ACTION_VIEW).apply { data = Linkify.tokenInfo(navigation.mint).toUri() } is NavigationTrigger.Chat.ById -> Intent(Intent.ACTION_VIEW).apply { - data = Linkify.chatById(navigation.chatId).toUri() + data = if (metadata?.chatType == ChatType.TIP_DM) { + Linkify.tipChatById(navigation.chatId).toUri() + } else { + Linkify.chatById(navigation.chatId).toUri() + } } is NavigationTrigger.Chat.ByContact -> Intent(Intent.ACTION_VIEW).apply { diff --git a/apps/flipcash/shared/payments/build.gradle.kts b/apps/flipcash/shared/payments/build.gradle.kts index c2ade13a2..7d839f71c 100644 --- a/apps/flipcash/shared/payments/build.gradle.kts +++ b/apps/flipcash/shared/payments/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt index de09ad1ca..6768f2aa9 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt @@ -1,29 +1,43 @@ package com.flipcash.shared.payments import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.buildTipDmPaymentMetadata import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatCoordinator import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.min /** - * Sends cash within a tip DM: derives the canonical tip chat for [ID], resolves the recipient's - * on-chain owner, attaches tip-DM app metadata, transfers, debits the local balance, and syncs the - * chat feed. Returns the canonical tip [ChatId] (for message reload / navigation), or null if it - * couldn't be derived. + * The tip-DM payment domain: sending cash to a user id and the tippable-amount bounds (min preset, + * max = send-limit ∧ balance) that an amount entry gates on. * - * The user-id counterpart to [ContactPaymentDelegate] (keyed by a phone number, attaching - * contact-DM metadata). Shared by the out-of-chat tip flow - * ([com.flipcash.shared.tipping.TippingCoordinator]) and the in-chat send (a tip DM opened in the - * messenger). Performs no UI or navigation — the caller owns send state, error presentation, and - * any post-send navigation. + * The user-id counterpart to [ContactPaymentDelegate] (phone number, contact-DM metadata). Shared by + * the out-of-chat tip flow ([com.flipcash.shared.tipping.TippingCoordinator], which surfaces these + * amounts through its selection state) and the in-chat send (a tip DM opened in the messenger, whose + * amount entry drives its "Swipe to Tip" label and minimum off these). [send] performs no UI or + * navigation — the caller owns send state, error presentation, and any post-send navigation. */ @Singleton class TipPaymentDelegate @Inject constructor( @@ -31,7 +45,83 @@ class TipPaymentDelegate @Inject constructor( private val transactionController: TransactionController, private val tokenCoordinator: TokenCoordinator, private val chatCoordinator: ChatCoordinator, + private val exchange: Exchange, + userFlags: UserFlagsCoordinator, ) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * The user's suggested tip amounts, derived from the server-provided per-region + * [com.flipcash.services.models.TipPresets] (via [UserFlagsCoordinator]) and the user's current + * preferred currency. Selects the presets whose region matches that currency (region is an ISO + * 4217 currency code), falls back to the USD presets, and expresses each tier — low / medium / + * high — as a [Fiat] in the matched region's currency. When the server provides no presets at + * all, falls back to the built-in [DEFAULT_USD_PRESETS], localized to the preferred currency. + */ + val tipPresets: StateFlow> = combine( + userFlags.resolvedFlags, + // Drive off the preferred rate (a StateFlow that always emits its current value) rather than + // observePreferredCurrency() — which can be an empty flow, and combine()'d with the hot + // resolvedFlags that never completes would hang forever. Re-derives when the region changes. + exchange.observePreferredRate(), + ) { flags, rate -> + val presets = flags.tipPresets.effectiveValue + val preferred = rate.currency + val (currency, matched) = + presets.firstOrNull { it.region.equals(preferred.name, ignoreCase = true) } + ?.let { preferred to it } + ?: presets.firstOrNull { it.region.equals(CurrencyCode.USD.name, ignoreCase = true) } + ?.let { CurrencyCode.USD to it } + ?: return@combine defaultPresets(preferred) + + listOf(matched.low, matched.medium, matched.high) + .map { amount -> Fiat(fiat = amount, currencyCode = currency) } + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * The largest tippable amount, in the user's preferred currency: the smaller of the + * per-transaction send limit and the selected token's balance — mirroring the give/cash/send + * amount entries. Null until limits/balance/rate are known. The amount entry surfaces it as an + * "enter up to" hint and blocks amounts above it. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val maxTipAmount: StateFlow = + combine( + transactionController.limits, + tokenCoordinator.observeSelectedTokenMint() + .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) }, + exchange.observePreferredRate(), + ) { limits, balance, rate -> + val balanceInLocal = balance.convertingTo(rate) + val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero + Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency) + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * The smallest tippable amount — the lowest preset tier in the user's preferred currency. The + * amount entry surfaces it as a "minimum tip" hint and blocks custom amounts below it. Null until + * presets resolve. + */ + val minTipAmount: StateFlow = + tipPresets.map { presets -> presets.minOrNull() } + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * Whether [amount] exceeds the per-transaction send limit for its currency — the amount entry + * gates on this before committing. Balance is enforced separately at send time; false when no + * limit is known for the currency. + */ + fun exceedsSendLimit(amount: Fiat): Boolean { + val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) ?: return false + return amount.toDouble() > limit.nextTransaction + } + + /** + * Sends [verifiedFiat] of [token] from [source] to the user identified by [userId] as a tip DM: + * derives the canonical tip chat, resolves the recipient's on-chain owner, attaches tip-DM app + * metadata, transfers, debits the local balance, and syncs the chat feed. Returns the canonical + * tip [ChatId] (for message reload / navigation), or null if it couldn't be derived. + */ suspend fun send( userId: ID, verifiedFiat: VerifiedFiat, @@ -61,4 +151,22 @@ class TipPaymentDelegate @Inject constructor( canonicalChatId } } + + /** + * The built-in fallback tip presets ([DEFAULT_USD_PRESETS], in USD), localized to [preferred] via + * the current exchange rate when the user isn't on USD. Leaves the amounts in USD if no rate is + * available for the preferred currency. + */ + private fun defaultPresets(preferred: CurrencyCode): List { + val usd = DEFAULT_USD_PRESETS.map { Fiat(fiat = it, currencyCode = CurrencyCode.USD) } + if (preferred == CurrencyCode.USD) return usd + + val rate = exchange.rateFor(preferred) ?: return usd + return usd.map { it.convertingTo(rate).rounded() } + } + + companion object { + /** Fallback tip amounts (USD) used when the server provides no presets. */ + private val DEFAULT_USD_PRESETS = listOf(5.0, 10.0, 20.0) + } } diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt new file mode 100644 index 000000000..59ff325b0 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.shared.payments + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Limits +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.SendLimit +import com.getcode.solana.keys.Mint +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TipPaymentDelegateTest { + + private val resolverController = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) + + // Per-transaction send limit of $100 for USD; no limit for any other currency. + private val limits = mockk { + every { sendLimitFor(any()) } returns null + every { sendLimitFor(CurrencyCode.USD) } returns + SendLimit(nextTransaction = 100.0, maxPerTransaction = 500.0, maxPerDay = 1000.0) + } + + private fun buildDelegate() = TipPaymentDelegate( + resolverController, + transactionController, + tokenCoordinator, + chatCoordinator, + exchange, + userFlags, + ) + + @Test + fun `exceedsSendLimit is true when the amount is over the per-transaction limit`() { + every { transactionController.limits } returns MutableStateFlow(limits) + + assertTrue(buildDelegate().exceedsSendLimit(Fiat(150.0, CurrencyCode.USD))) + } + + @Test + fun `exceedsSendLimit is false when the amount is within the limit`() { + every { transactionController.limits } returns MutableStateFlow(limits) + + assertFalse(buildDelegate().exceedsSendLimit(Fiat(100.0, CurrencyCode.USD))) + } + + @Test + fun `exceedsSendLimit is false when no limit is known for the currency`() { + every { transactionController.limits } returns MutableStateFlow(limits) + + // No send limit configured for CAD — nothing to enforce, so it's allowed. + assertFalse(buildDelegate().exceedsSendLimit(Fiat(999.0, CurrencyCode.CAD))) + } + + @Test + fun `maxTipAmount is the smaller of the send limit and the selected token balance`() = runTest { + every { transactionController.limits } returns MutableStateFlow(limits) + every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(Mint(listOf(1))) + every { tokenCoordinator.balanceForToken(any()) } returns flowOf(Fiat(40.0, CurrencyCode.USD)) + every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne) + + val max = buildDelegate().maxTipAmount.first { it != null } + + // Send limit is $100, balance is $40 — the smaller wins. + assertEquals(40.0, max!!.toDouble()) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/24.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/24.json new file mode 100644 index 000000000..3983f42cf --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/24.json @@ -0,0 +1,668 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "a12b10d8fa123d9a5686c04115d89a05", + "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, `is_hidden` 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" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "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, 'a12b10d8fa123d9a5686c04115d89a05')" + ] + } +} \ 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 cdb47e66b..9a614f43e 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 @@ -71,8 +71,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 20, to = 21), AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23, spec = FlipcashDatabase.Migration22To23::class), + AutoMigration(from = 23, to = 24), ], - version = 23, + version = 24, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 0eef68d44..93c6d0821 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -11,4 +11,5 @@ data class ChatMetadataEntity( @ColumnInfo(name = "last_activity_epoch_ms", index = true) val lastActivityEpochMs: Long, @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") val latestEventSequence: Long = 0, + @ColumnInfo(name = "is_hidden", defaultValue = "0") val isHidden: Boolean = false, ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 1871a90dd..711a720d8 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -54,6 +54,7 @@ class ChatEntityMapper @Inject constructor() { lastActivityEpochMs = metadata.lastActivity.toEpochMilliseconds(), lastMessageId = metadata.lastMessage?.messageId, latestEventSequence = metadata.latestEventSequence, + isHidden = metadata.isHidden, ) } @@ -69,6 +70,7 @@ class ChatEntityMapper @Inject constructor() { lastMessage = lastMessage, lastActivity = Instant.fromEpochMilliseconds(entity.lastActivityEpochMs), latestEventSequence = entity.latestEventSequence, + isHidden = entity.isHidden, ) } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 3c638b59d..e4e63eb88 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -68,6 +68,10 @@ internal class AppRouter( listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(type.identifier)) ) + is DeeplinkType.TipChat -> DeeplinkAction.Navigate( + listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) + ) + is DeeplinkType.Tipcard -> DeeplinkAction.PresentTipCard(type.userId) } } @@ -79,6 +83,7 @@ internal class AppRouter( deepLink.isToken() -> deepLink.handleTokenLink() deepLink.isEmailVerification() -> deepLink.handleEmailVerification() deepLink.isChat() -> deepLink.handleChat() + deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() else -> null } @@ -135,6 +140,10 @@ private fun DeepLink.isEmailVerification(): Boolean = verification.contains(path private fun DeepLink.isChat(): Boolean = chat.contains(pathSegments.getOrNull(0)) +// https://app.flipcash.com/tip/chat/{url encoded chatId} +private fun DeepLink.isTipChat(): Boolean = + tip.contains(pathSegments.getOrNull(0)) && chat.contains(pathSegments.getOrNull(1)) + private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0)) private fun DeepLink.handleLoginLink(): DeeplinkType.Login? { @@ -179,6 +188,17 @@ private fun DeepLink.handleChat(): DeeplinkType.Chat? { return DeeplinkType.Chat(identifier) } +// https://app.flipcash.com/tip/chat/{url encoded chatId} +private fun DeepLink.handleTipChat(): DeeplinkType.TipChat? { + val uri = data.toUri() + // Tip chats are always addressed by canonical chat id (base64 url-safe bytes), + // never by phone number, so there is no ByContact branch here. + val chatTarget = uri.pathSegments.getOrNull(2) ?: return null + val chatId = ChatId(chatTarget.decodeBase64UrlSafe().toList()) + + return DeeplinkType.TipChat(ChatIdentifier.ByChatId(chatId)) +} + private fun DeepLink.handleTipCard(): DeeplinkType.Tipcard? { val uri = data.toUri() val userId = uri.pathSegments.getOrNull(1) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index cc4936be1..0796884c1 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -2,8 +2,11 @@ package com.flipcash.app.router.internal import android.util.Base64 import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.core.util.Linkify +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.AuthState import com.getcode.solana.keys.Mint import dev.theolm.rinku.DeepLink @@ -222,6 +225,48 @@ class AppRouterTest { // endregion + // region classify + dispatch — TipChat + + private val sampleChatId = ChatId(ByteArray(32) { it.toByte() }) + + @Test + fun `classify recognizes tip chat deeplink and round-trips the chat id`() { + val type = router.classify(DeepLink(Linkify.tipChatById(sampleChatId))) + assertIs(type) + val identifier = type.identifier + assertIs(identifier) + assertEquals(sampleChatId, identifier.chatId) + } + + @Test + fun `classify returns null for tip chat without chat id segment`() { + val type = router.classify(DeepLink("https://app.flipcash.com/tip/chat")) + assertNull(type) + } + + @Test + fun `classify still recognizes tip card deeplink (not swallowed by tip chat)`() { + val userId = "11111111-1111-1111-1111-111111111111" + val type = router.classify(DeepLink("https://app.flipcash.com/tip/$userId")) + assertIs(type) + } + + @Test + fun `dispatch returns Navigate with tips sheet and chat for tip chat deeplink`() { + loggedIn() + val action = router.dispatch(DeepLink(Linkify.tipChatById(sampleChatId))) + assertIs(action) + assertEquals(2, action.routes.size) + assertIs(action.routes[0]) + val chat = action.routes[1] + assertIs(chat) + val identifier = chat.identifier + assertIs(identifier) + assertEquals(sampleChatId, identifier.chatId) + } + + // endregion + // region dispatch — Logged in: EmailVerification (route building) @Test diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index 0f0fdc20a..ab90231b0 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -9,14 +9,12 @@ import com.flipcash.app.core.tipping.TipSelectionState import com.flipcash.app.currency.PreferredCurrencyController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.OpenCodePayload import com.getcode.opencode.model.core.PayloadKind @@ -24,9 +22,7 @@ import com.getcode.opencode.model.core.UserId import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError -import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.view.LoadingSuccessState @@ -49,7 +45,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton -import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds /** @@ -61,13 +56,11 @@ import kotlin.time.Duration.Companion.milliseconds */ @Singleton class TippingCoordinator @Inject constructor( - userFlags: UserFlagsCoordinator, private val profileController: ProfileController, private val userManager: UserManager, private val tipPaymentDelegate: TipPaymentDelegate, private val exchange: Exchange, private val tokenCoordinator: TokenCoordinator, - private val transactionController: TransactionController, private val verifiedFiatCalculator: VerifiedFiatCalculator, private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, @@ -101,40 +94,6 @@ class TippingCoordinator @Inject constructor( tokenCoordinator.tokens.map { tokens -> tokens.find { it.address == mint } } } - /** - * The user's suggested tip amounts, derived from the server-provided per-region - * [com.flipcash.services.models.TipPresets] (via [UserFlagsCoordinator]) and the user's current - * preferred currency. Selects the presets whose region matches that currency (region is an ISO - * 4217 currency code), falls back to the USD presets, and expresses each tier — low / medium / - * high — as a [Fiat] in the matched region's currency. When the server provides no presets at - * all, falls back to the built-in [DEFAULT_USD_PRESETS], localized to the preferred currency. - */ - private val tipPresets: Flow> = combine( - userFlags.resolvedFlags, - // Drive off the preferred rate (a StateFlow that always emits its current value) rather than - // observePreferredCurrency() — which can be an empty flow, and combine()'d with the hot - // resolvedFlags that never completes would hang firstOrNull() forever (blocking card resolve). - // Re-derives when the region changes so the tip modal's presets follow it. - exchange.observePreferredRate(), - ) { flags, rate -> - val presets = flags.tipPresets.effectiveValue - val preferred = rate.currency - val (currency, matched) = - presets.firstOrNull { it.region.equals(preferred.name, ignoreCase = true) } - ?.let { preferred to it } - ?: presets.firstOrNull { - it.region.equals( - CurrencyCode.USD.name, - ignoreCase = true - ) - } - ?.let { CurrencyCode.USD to it } - ?: return@combine defaultPresets(preferred) - - listOf(matched.low, matched.medium, matched.high) - .map { amount -> Fiat(fiat = amount, currencyCode = currency) } - } - /** The combined tip selection (amount chosen in the modal + app-global token + send state). */ override val selection: StateFlow = combine( @@ -147,47 +106,18 @@ class TippingCoordinator @Inject constructor( canTip = canTip, ) }, - tipPresets, + tipPaymentDelegate.tipPresets, ) { state, presets -> state.copy(presets = presets) } .stateIn(scope, SharingStarted.WhileSubscribed(5_000), TipSelectionState()) - /** - * The largest tippable amount, in the user's preferred currency: the smaller of the - * per-transaction send limit and the selected token's balance — mirroring the give/cash/send - * amount entries. Null until limits/balance/rate are known. The amount-entry sheet surfaces it - * as an "enter up to" hint and blocks amounts above it. - */ - @OptIn(ExperimentalCoroutinesApi::class) - val maxTipAmount: StateFlow = - combine( - transactionController.limits, - tokenCoordinator.observeSelectedTokenMint() - .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) }, - exchange.observePreferredRate(), - ) { limits, balance, rate -> - val balanceInLocal = balance.convertingTo(rate) - val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero - Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency) - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + /** The largest tippable amount (send-limit ∧ balance), surfaced by the amount entry. */ + val maxTipAmount: StateFlow get() = tipPaymentDelegate.maxTipAmount - /** - * The smallest tippable amount — the lowest preset tier in the user's preferred currency. The - * amount entry surfaces it as a "minimum tip" hint and blocks custom amounts below it. Null until - * presets resolve. - */ - val minTipAmount: StateFlow = - tipPresets.map { presets -> presets.minOrNull() } - .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + /** The smallest tippable amount (lowest preset tier), surfaced by the amount entry. */ + val minTipAmount: StateFlow get() = tipPaymentDelegate.minTipAmount - /** - * Whether [amount] exceeds the per-transaction send limit for its currency — the tip amount - * entry gates on this before committing. Balance is enforced separately at send time - * ([confirmTip]); false when no limit is known for the currency. - */ - fun exceedsSendLimit(amount: Fiat): Boolean { - val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) ?: return false - return amount.toDouble() > limit.nextTransaction - } + /** Whether [amount] exceeds the per-transaction send limit for its currency. */ + fun exceedsSendLimit(amount: Fiat): Boolean = tipPaymentDelegate.exceedsSendLimit(amount) override fun selectAmount(amount: TipAmount?) { _amount.value = amount @@ -345,19 +275,6 @@ class TippingCoordinator @Inject constructor( _sendState.value = state } - /** - * The built-in fallback tip presets ([DEFAULT_USD_PRESETS], in USD), localized to [preferred] - * via the current exchange rate when the user isn't on USD. Leaves the amounts in USD if no - * rate is available for the preferred currency. - */ - private fun defaultPresets(preferred: CurrencyCode): List { - val usd = DEFAULT_USD_PRESETS.map { Fiat(fiat = it, currencyCode = CurrencyCode.USD) } - if (preferred == CurrencyCode.USD) return usd - - val rate = exchange.rateFor(preferred) ?: return usd - return usd.map { it.convertingTo(rate).rounded() } - } - /** * Assembles the scannable [Scannable.TipCard]: the tip [OpenCodePayload] encoding [userId] * as the scannable code data, plus [profile] for rendering. Tip presets are surfaced reactively @@ -370,9 +287,4 @@ class TippingCoordinator @Inject constructor( user = profile, ) } - - companion object { - /** Fallback tip amounts (USD) used when the server provides no presets. */ - private val DEFAULT_USD_PRESETS = listOf(5.0, 10.0, 20.0) - } } diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt index 1bd2e957e..826e786c0 100644 --- a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt +++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt @@ -1,65 +1,39 @@ package com.flipcash.shared.tipping -import com.flipcash.app.currency.PreferredCurrencyController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate -import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiatCalculator -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.Limits -import com.getcode.opencode.model.financial.Rate -import com.getcode.opencode.model.financial.SendLimit -import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertSame -import kotlin.test.assertTrue class TippingCoordinatorTest { private val profileController = mockk() private val userManager = mockk() private val tipPaymentDelegate = mockk(relaxed = true) - private val userFlags = mockk(relaxed = true) - private val preferredCurrency = mockk(relaxed = true) private val exchange = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) - private val transactionController = mockk(relaxed = true) private val verifiedFiatCalculator = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val purchaseMethodController = mockk(relaxed = true) - // Per-transaction send limit of $100 for USD; no limit for any other currency. - private val limits = mockk { - every { sendLimitFor(any()) } returns null - every { sendLimitFor(CurrencyCode.USD) } returns - SendLimit(nextTransaction = 100.0, maxPerTransaction = 500.0, maxPerDay = 1000.0) - } - private fun buildCoordinator() = TippingCoordinator( - userFlags, profileController, userManager, tipPaymentDelegate, exchange, tokenCoordinator, - transactionController, verifiedFiatCalculator, resources, purchaseMethodController, @@ -113,39 +87,4 @@ class TippingCoordinatorTest { assertEquals(id, coordinator.currentUserId) } - - @Test - fun `exceedsSendLimit is true when the amount is over the per-transaction limit`() { - every { transactionController.limits } returns MutableStateFlow(limits) - - assertTrue(coordinator.exceedsSendLimit(Fiat(150.0, CurrencyCode.USD))) - } - - @Test - fun `exceedsSendLimit is false when the amount is within the limit`() { - every { transactionController.limits } returns MutableStateFlow(limits) - - assertFalse(coordinator.exceedsSendLimit(Fiat(100.0, CurrencyCode.USD))) - } - - @Test - fun `exceedsSendLimit is false when no limit is known for the currency`() { - every { transactionController.limits } returns MutableStateFlow(limits) - - // No send limit configured for CAD — nothing to enforce, so it's allowed. - assertFalse(coordinator.exceedsSendLimit(Fiat(999.0, CurrencyCode.CAD))) - } - - @Test - fun `maxTipAmount is the smaller of the send limit and the selected token balance`() = runTest { - every { transactionController.limits } returns MutableStateFlow(limits) - every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(Mint(listOf(1))) - every { tokenCoordinator.balanceForToken(any()) } returns flowOf(Fiat(40.0, CurrencyCode.USD)) - every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne) - - val max = buildCoordinator().maxTipAmount.first { it != null } - - // Send limit is $100, balance is $40 — the smaller wins. - assertEquals(40.0, max!!.toDouble()) - } } diff --git a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto index fa750d9ef..45c6084a4 100644 --- a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto @@ -45,6 +45,11 @@ message Metadata { // exceed last_message.event_sequence. It is the same head reported by // GetDeltaResponse.latest_sequence. uint64 latest_event_sequence = 6; + + // Whether this chat is hidden from the requesting owner's chat list. + // Per-viewer and server-computed (e.g. the chat's peer is on the caller's + // blocklist). Clients should exclude hidden chats from the primary DM list. + bool is_hidden = 7; } message Member { diff --git a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto index ee77691fc..35bfcd075 100644 --- a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto @@ -6,6 +6,7 @@ option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/g option java_package = "com.codeinc.flipcash.gen.push.v1"; option objc_class_prefix = "FPBPushV1"; +import "chat/v1/model.proto"; import "common/v1/common.proto"; import "phone/v1/model.proto"; import "validate/validate.proto"; @@ -45,6 +46,8 @@ message Payload { string group_key = 5 [(validate.rules).string = { max_len: 4096 // Arbitrary }]; + + ChatMetadata chat_metadata = 6; } // Navigation within the app upon clicking the push @@ -77,3 +80,17 @@ message Substitution { phone.v1.PhoneNumber contact = 2; } } + +// Additional metadata provided for chat pushes +message ChatMetadata { + // The user ID that sent a chat message + // + // Note: This will not be set for system messages OR for notifications that + // don't relate to a user + common.v1.UserId sending_user_id = 1; + + // The type of chat + chat.v1.ChatType type = 2 [(validate.rules).enum = { + not_in: [0] // UNKNOWN + }]; +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index 687754a73..f2d28ae8e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -29,6 +29,7 @@ class ChatMetadataMapper @Inject constructor( lastMessage = if (from.hasLastMessage()) from.lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(from.lastActivity.seconds, from.lastActivity.nanos), latestEventSequence = from.latestEventSequence, + isHidden = from.isHidden, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 649bc0ac2..e68652727 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -2,6 +2,7 @@ package com.flipcash.services.internal.network.extensions import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.push.v1.navigation import com.codeinc.flipcash.gen.push.v1.navigationOrNull import com.codeinc.flipcash.gen.push.v1.Model as PushModels import com.flipcash.services.internal.extensions.toChecksum @@ -11,6 +12,7 @@ import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.services.models.PushChatMetadata import com.flipcash.services.models.PagingToken import com.flipcash.services.models.Substitution import com.flipcash.services.models.UserProfile @@ -100,12 +102,20 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { val titleSubs = titleSubstitutionsList.mapNotNull { it.asSubstitution() } val bodySubs = bodySubstitutionsList.mapNotNull { it.asSubstitution() } + val pushChatMetadata = if (hasChatMetadata()) { + PushChatMetadata( + sendingUserId = if (chatMetadata.hasSendingUserId()) chatMetadata.sendingUserId.toId() else null, + chatType = chatMetadata.type.toChatType(), + ) + } else null + return NotificationPayload( navigation = navigationTrigger, category = notificationCategory, groupKey = groupKey, titleSubstitutions = titleSubs, bodySubstitutions = bodySubs, + chatMetadata = pushChatMetadata, ) } @@ -361,6 +371,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), latestEventSequence = latestEventSequence, + isHidden = isHidden, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index 2291de0a6..c5bb62af0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -3,6 +3,8 @@ package com.flipcash.services.models import com.codeinc.flipcash.gen.push.v1.Model import com.flipcash.services.internal.network.extensions.asPayload import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint import com.getcode.utils.decodeBase64 @@ -22,12 +24,24 @@ sealed interface Substitution { ): Substitution } +/** + * Additional metadata attached to chat-related push payloads. + * + * [sendingUserId] is null for system messages or notifications not tied to a user, + * mirroring the proto's optional `sending_user_id` field. + */ +data class PushChatMetadata( + val sendingUserId: ID?, + val chatType: ChatType, +) + data class NotificationPayload( val navigation: NavigationTrigger?, val category: NotificationCategory = NotificationCategory.DEFAULT, val groupKey: String = "", val titleSubstitutions: List = emptyList(), val bodySubstitutions: List = emptyList(), + val chatMetadata: PushChatMetadata? = null, ) { companion object { fun fromEncoded(encoded: String): NotificationPayload? { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index ff3137669..3ee4b9dfe 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -9,4 +9,5 @@ data class ChatMetadata( val lastMessage: ChatMessage?, val lastActivity: Instant, val latestEventSequence: Long = 0, + val isHidden: Boolean = false, ) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt index a19cd02c4..c438a2db9 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapperTest.kt @@ -89,6 +89,18 @@ class ChatMetadataMapperTest { assertEquals(2000L, result.lastActivity.epochSeconds) } + @Test + fun `maps isHidden field`() { + val result = mapper.map(metadata { setIsHidden(true) }) + assertEquals(true, result.isHidden) + } + + @Test + fun `isHidden defaults to false`() { + val result = mapper.map(metadata()) + assertEquals(false, result.isHidden) + } + @Test fun `maps members with pointers`() { val result = mapper.map(metadata { addMembers(member()) })