diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt new file mode 100644 index 0000000000..5ede525f5c --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/TransactionRouterModule.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.inject + +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatHydration +import com.flipcash.shared.transactionhistory.TransactionRouter +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object TransactionRouterModule { + /** + * A send-back's activity row opens its DM only when the chat is stored or the server still has + * it. An absent or unreachable chat opens the details instead. + */ + @Provides + fun providesTransactionRouter(chats: ChatCoordinator): TransactionRouter = + TransactionRouter.forChats { chatId -> + when (chats.hydrateChat(chatId)) { + ChatHydration.Stored, is ChatHydration.Fetched -> true + else -> false + } + } +} diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index fbc6599a7c..e8d46f48ef 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -2064,6 +2064,23 @@ You and %1$d others received from %1$s + + You sent back + + %1$s sent back + + to You + + Send Back + + Sent Back + + Send back to %1$s + Swipe to Send Back + Couldn\'t Send Back + Something went wrong. Please try again. + + Sent back to %1$s Someone Sent diff --git a/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt b/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt index 6fc32c8c25..5209fb7fe7 100644 --- a/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt +++ b/apps/flipcash/features/balance/src/androidMain/kotlin/com/flipcash/app/balance/internal/HiltWalletViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.TransactionRouter import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -22,6 +23,7 @@ internal class HiltWalletViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, walletReveal: WalletRevealCoordinator, + transactionRouter: TransactionRouter, ) : WalletViewModel( userManager = userManager, userFlags = userFlags, @@ -31,4 +33,5 @@ internal class HiltWalletViewModel @Inject constructor( feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, walletReveal = walletReveal, + transactionRouter = transactionRouter, ) diff --git a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 9d01f8ba60..b6b6a1a6f4 100644 --- a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -288,9 +288,7 @@ internal fun WalletScreenContent( } .padding(top = grid.x4, bottom = grid.x1), onItemClick = { item -> - dispatchEvent( - WalletViewModel.Event.OpenScreen(AppRoute.Sheets.TransactionDetails(item.messageId)) - ) + dispatchEvent(WalletViewModel.Event.OpenTransaction(item)) }, ) } diff --git a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index b93c8c3132..674da52a01 100644 --- a/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/commonMain/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.shared.transactionhistory.FeedSyncState import com.flipcash.shared.transactionhistory.TransactionListItem +import com.flipcash.shared.transactionhistory.TransactionRouter import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.WalletReveal @@ -40,6 +41,7 @@ open class WalletViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, walletReveal: WalletRevealCoordinator, + transactionRouter: TransactionRouter = TransactionRouter.DetailsOnly, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -117,6 +119,7 @@ open class WalletViewModel @Inject constructor( data object OpenCurrencySelection : Event data class OpenScreen(val screen: AppRoute) : Event + data class OpenTransaction(val item: TransactionListItem) : Event data object PresentDepositOptions: Event } @@ -151,6 +154,11 @@ open class WalletViewModel @Inject constructor( .onEach { provider -> dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.OpenScreen(transactionRouter.routeFor(it.item))) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { @@ -211,6 +219,7 @@ open class WalletViewModel @Inject constructor( } Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.OpenTransaction -> { state -> state } } } } 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 74c700e487..c1b6dd04f5 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 @@ -178,6 +178,9 @@ import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.group.GroupSendInsufficientBalance import com.flipcash.shared.payments.group.GroupSendPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.app.messenger.internal.sendback.SendBackFlow import com.getcode.libs.emojis.reactions.EmojiCatalogLoader import com.getcode.libs.emojis.reactions.EmojiDrawability import com.getcode.libs.emojis.reactions.RecentReactions @@ -327,6 +330,9 @@ internal class ChatViewModel @AssistedInject constructor( // Last and defaulted so the transcript tests, which hold no saved previews, need not supply one. linkCardMemory: LinkCardMemory? = null, private val composerLinkPreview: ComposerLinkPreview? = null, + // Defaulted for the same reason: only the Send Back tests supply them. + sendBackPayments: SendBackPaymentDelegate? = null, + chatMessagingController: ChatMessagingController? = null, ) : BaseViewModel( initialState = State( openingAtMessage = openAtMessageId != null, @@ -1400,6 +1406,46 @@ internal class ChatViewModel @AssistedInject constructor( .flatMapLatest { chatCoordinator.observeChatReactions(it) } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + /** + * Send Back: returning a DM's opening payment. The transcript notes each cash message to it as + * it maps them, and its rows join the built pages as an overlay, like [reactionOverlay]. Null + * where its collaborators are not supplied (tests that don't exercise it). + */ + private val sendBack: SendBackFlow? = + if (sendBackPayments != null && chatMessagingController != null) { + SendBackFlow( + scope = viewModelScope, + payments = sendBackPayments, + returnStatus = chatMessagingController::getCashMessageReturnStatus, + payer = startChattingPayer, + tokenCoordinator = tokenCoordinator, + userManager = userManager, + resources = resources, + analytics = analytics, + chatId = { stateFlow.value.chatId }, + peerName = { stateFlow.value.participant?.name }, + openScreen = { route -> dispatchEvent(Event.OpenScreen(route)) }, + ) + } else { + null + } + + /** The Send Back confirm sheet, or null when it is not up. */ + val sendBackSheet: StateFlow = + sendBack?.sheet ?: MutableStateFlow(null) + + fun onSendBack(messageId: Long) { + sendBack?.tap(messageId) + } + + fun confirmSendBack() { + sendBack?.confirm() + } + + fun dismissSendBack() { + sendBack?.dismiss() + } + /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's * counterparty is already named in the title bar and attributing each of their bubbles would be @@ -1708,6 +1754,10 @@ internal class ChatViewModel @AssistedInject constructor( } } + (enriched as? MessageContent.Cash)?.let { cash -> + sendBack?.note(message.messageId, cash, message.senderId, message.isFromSelf) + } + ChatListItem.ContentBubble( messageId = message.messageId, contentIndex = index, @@ -1804,7 +1854,22 @@ internal class ChatViewModel @AssistedInject constructor( ) } } - }.cachedIn(pagingScope) + } + // Send Back's rows, the same way: an overlay on the built pages keyed by message id, so a + // status answer or a tap re-maps one field without re-running the pass above. + .let { pages -> + val rows = sendBack?.rows ?: return@let pages + combine(pages, rows) { data, overlay -> + if (overlay.isEmpty()) return@combine data + data.map { item -> + if (item !is ChatListItem.ContentBubble || item.content !is MessageContent.Cash) { + return@map item + } + overlay[item.messageId]?.let { item.copy(sendBack = it) } ?: item + } + } + } + .cachedIn(pagingScope) /** * The transcript as the list draws it: [mappedMessages] with date separators and the unread 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 a1d6b477d7..cd2e4a0a76 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 @@ -71,6 +71,7 @@ import com.flipcash.app.core.links.LocalTrustedWebsites import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.cash.SendBackSheetHost import com.flipcash.app.messenger.internal.MemberModerationViewModel import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.messenger.internal.screens.profile.memberTapStep @@ -464,6 +465,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.RefreshReactionIds -> { viewModel.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(action.messageIds)) } + + // Keyboard first, so the confirm sheet or the balance modal rises over a settled chat. + is ChatAction.SendBack -> keyboard.hideIfVisible { viewModel.onSendBack(action.messageId) } } Unit @@ -796,6 +800,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + val sendBackSheet by viewModel.sendBackSheet.collectAsStateWithLifecycle() + SendBackSheetHost( + sheet = sendBackSheet, + onConfirm = viewModel::confirmSendBack, + onDismiss = viewModel::dismissSendBack, + ) + // In-window, over the conversation and the deck, so the bottom bar draws above it. RequestPaySheet( pay = requestPayState, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt new file mode 100644 index 0000000000..1ba7c32af3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/SendBackSheet.kt @@ -0,0 +1,128 @@ +package com.flipcash.app.messenger.internal.screens.cash + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.messenger.internal.sendback.SendBackFlow +import com.flipcash.features.messenger.R +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.ui.components.SlideToConfirm + +/** + * The Send Back confirm sheet over the chat, held in-screen like the fee sheet. The sheet stays + * composed until it has slid away, so a dismissal from the flow (the swipe, or a balance that + * dropped meanwhile) animates out like a drag does. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SendBackSheetHost( + sheet: SendBackFlow.Sheet?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var shown by remember { mutableStateOf(null) } + LaunchedEffect(sheet) { + if (sheet != null) { + shown = sheet + } else if (shown != null) { + sheetState.hide() + shown = null + } + } + val current = shown ?: return + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = CodeTheme.colors.background, + ) { + SendBackSheet( + amount = current.amount, + token = current.token, + recipientName = current.recipientName, + onConfirm = onConfirm, + ) + } +} + +/** + * Returning a DM's opening payment: the original's amount, its currency read-only (the contract + * fixes currency and mint), who it goes back to, and the swipe. [ChatInitPaymentSheet]'s layout + * with the picker taken out. + */ +@Composable +internal fun SendBackSheet( + amount: Fiat, + token: Token?, + recipientName: String?, + onConfirm: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = amount.formatted(), + style = CodeTheme.typography.displayLarge, + color = CodeTheme.colors.textMain, + ) + + TokenSelectionPill( + token = token, + background = White10, + textStyle = CodeTheme.typography.textMedium, + imageSize = CodeTheme.dimens.staticGrid.x3, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + showsPicker = false, + onClick = {}, + ) + + Text( + text = stringResource( + R.string.label_sendBackTo, + recipientName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.title_unnamedUser), + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + + SlideToConfirm( + onConfirm = onConfirm, + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.action_swipeToSendBack), + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt new file mode 100644 index 0000000000..ba1add2c0f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlow.kt @@ -0,0 +1,311 @@ +package com.flipcash.app.messenger.internal.sendback + +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.CashMessageReturnStatus +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.models.SendBackRow +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.LiveSecrets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Send Back for one open DM: returning its opening payment to the person who paid it. + * + * The transcript [note]s each cash message as it maps it. From that, [rows] says what each bubble + * draws under its lines: a return in the transcript marks the message it names Sent Back, for both + * people; an offered message the viewer received gets one status check per DM open, and its row + * follows that answer and the viewer's taps. + * + * The server's `payment_intent_id` lives in [intentIds] only, for as long as this DM is open. It is + * never put in [rows] or [sheet], so it can't reach a log through a state's toString, and it is + * held in [LiveSecrets] from the moment it arrives. + */ +internal class SendBackFlow( + private val scope: CoroutineScope, + private val payments: SendBackPaymentDelegate, + private val returnStatus: suspend (ChatId, Long) -> Result, + private val payer: StartChattingPayer, + private val tokenCoordinator: TokenCoordinator, + private val userManager: UserManager, + private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, + /** The open DM, or null before it resolves. */ + private val chatId: () -> ChatId?, + /** The DM peer's name as the chat title shows it. */ + private val peerName: () -> String?, + /** Where "Add Money" goes. */ + private val openScreen: (AppRoute) -> Unit, +) { + /** What the confirm sheet draws. No intent id: see the class comment. */ + data class Sheet( + val messageId: Long, + val amount: Fiat, + val token: Token, + val recipientName: String?, + ) + + /** An offered payment the viewer received, as much of it as a return needs. */ + private data class Offer(val messageId: Long, val amount: Fiat, val mint: Mint, val payee: ID) + + private val lock = Any() + private val offers = mutableMapOf() + private val checked = mutableSetOf() + private val intentIds = mutableMapOf() + + /** Ids of messages a return in the transcript names. */ + private val returned = MutableStateFlow>(emptySet()) + + /** The row each offered message's status and the viewer's taps have put it in. */ + private val local = MutableStateFlow>(emptyMap()) + + val rows: StateFlow> = + combine(local, returned) { byState, byTranscript -> + byState + byTranscript.associateWith { SendBackRow.SentBack } + }.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + private val _sheet = MutableStateFlow(null) + val sheet: StateFlow = _sheet.asStateFlow() + + /** A tap or swipe is between its balance check and its outcome. */ + private var busy = false + + /** + * Called for each cash message as the transcript maps it. Pages load newest first, and a return + * is always later in the DM than what it returns, so a loaded return is noted before its + * original: the status check below is skipped for an original whose return is already here. + */ + fun note(messageId: Long, cash: MessageContent.Cash, senderId: ID?, isFromSelf: Boolean) { + cash.paymentReturn?.returnOf?.let { of -> + if (of.messageId !in returned.value) returned.update { it + of.messageId } + } + if (cash.paymentReturn?.offer == null || isFromSelf || senderId == null) return + val self = userManager.accountId ?: return + // The offer is on the sender's copy too; only its single receiver may send it back. + if (cash.receiver != self) return + val chat = chatId() ?: return + synchronized(lock) { + if (!checked.add(messageId)) return + offers[messageId] = Offer(messageId, cash.amount, cash.mint, senderId) + } + scope.launch { checkStatus(chat, messageId) } + } + + private suspend fun checkStatus(chat: ChatId, messageId: Long) { + if (messageId in returned.value) return + // A failure leaves the row hidden, with no error; the next DM open asks again. + val status = returnStatus(chat, messageId).getOrNull() ?: return + when (status.status) { + CashMessageReturnStatus.Status.AVAILABLE -> { + status.paymentIntentId?.let { keep(messageId, it) } + setRowIfUnset(messageId, SendBackRow.Available) + } + CashMessageReturnStatus.Status.RETURNED -> setRow(messageId, SendBackRow.SentBack) + CashMessageReturnStatus.Status.UNKNOWN -> Unit + } + } + + /** The receiver tapped Send Back under [messageId]. */ + fun tap(messageId: Long) { + if (rows.value[messageId] != SendBackRow.Available || _sheet.value != null) return + val offer = synchronized(lock) { offers[messageId] } ?: return + if (!claim()) return + scope.launch { + try { + val token = tokenFor(offer.mint) + val hasBalance = token?.let { checkBalance(offer, it) }?.hasBalance + if (token == null || hasBalance == null) { + // Not a tap the funnel counts: it reached neither the sheet nor the modal. + presentRateUnavailable() + return@launch + } + analytics.track(TransferEvents.sendBackTapped(hasBalance = hasBalance)) + if (hasBalance) { + _sheet.value = Sheet(messageId, offer.amount, token, peerName()) + } else { + presentInsufficientBalance() + } + } finally { + busy = false + } + } + } + + /** + * The swipe. The balance can change while the sheet is up, so it is checked again, and the + * amount that check priced is the one paid. + */ + fun confirm() { + val open = _sheet.value ?: return + val offer = synchronized(lock) { offers[open.messageId] } ?: return + if (!claim()) return + scope.launch { + val check = checkBalance(offer, open.token) + _sheet.value = null + if (check?.hasBalance != true) { + busy = false + if (check != null) presentInsufficientBalance() else presentRateUnavailable() + return@launch + } + setRow(offer.messageId, SendBackRow.Sending) + try { + submit(offer, open.token, check.priced) + } finally { + busy = false + } + } + } + + fun dismiss() { + _sheet.value = null + } + + private suspend fun submit(offer: Offer, token: Token, priced: VerifiedFiat) { + val id = offer.messageId + val chat = chatId() + val owner = userManager.accountCluster + if (chat == null || owner == null) { + failed(offer, IllegalStateException("no chat or account")) + return + } + val intentId = synchronized(lock) { intentIds[id] } ?: run { + val status = returnStatus(chat, id).getOrNull() + if (status?.status == CashMessageReturnStatus.Status.RETURNED) { + alreadyReturned(offer) + return + } + status?.paymentIntentId?.takeIf { status.status == CashMessageReturnStatus.Status.AVAILABLE } + ?.also { keep(id, it) } + } + if (intentId == null) { + failed(offer, IllegalStateException("no payment intent id")) + return + } + + payments.pay( + chatId = chat, + messageId = id, + payee = offer.payee, + priced = priced, + token = token, + source = owner.withTimelockForToken(token), + intentId = intentId, + ).onSuccess { + setRow(id, SendBackRow.SentBack) + analytics.track(sentCash(AnalyticsState.SUCCESS, offer, null)) + }.onFailure { cause -> + // The outcome is unknown: a second tap or another device may have got there first. + val after = returnStatus(chat, id).getOrNull() + if (after?.status == CashMessageReturnStatus.Status.RETURNED) { + alreadyReturned(offer) + } else { + failed(offer, cause) + } + } + } + + private fun alreadyReturned(offer: Offer) { + setRow(offer.messageId, SendBackRow.SentBack) + analytics.track(sentCash(AnalyticsState.FAILURE, offer, ALREADY_RETURNED)) + } + + private fun failed(offer: Offer, cause: Throwable) { + setRow(offer.messageId, SendBackRow.Available) + analytics.track(sentCash(AnalyticsState.FAILURE, offer, cause.analytics)) + presentFailed() + } + + private fun sentCash(state: AnalyticsState, offer: Offer, error: String?) = + TransferEvents.sentCash(state, offer.amount.analytics, error, SendContext.SEND_BACK) + + /** [offer] priced once at today's rate, and whether the viewer holds that much. */ + private class BalanceCheck(val priced: VerifiedFiat, val hasBalance: Boolean) + + /** + * Prices [offer] and compares the token quarks it needs with the quarks the viewer holds, + * exactly. Null when that can't be decided: no fresh rate to price it, or no quark balance + * loaded yet. A fiat comparison is no stand-in, since rounding passes a balance just short. + */ + private suspend fun checkBalance(offer: Offer, token: Token): BalanceCheck? { + val priced = payments.price(offer.amount, token).getOrNull() ?: return null + val needed = priced.localFiat.underlyingTokenAmount.quarks + val held = tokenCoordinator.heldQuarksForToken(offer.mint).first() ?: return null + return BalanceCheck(priced, held >= needed) + } + + private suspend fun tokenFor(mint: Mint): Token? = + tokenCoordinator.cachedToken(mint) ?: tokenCoordinator.getTokenMetadata(mint).getOrNull()?.token + + private fun keep(messageId: Long, intentId: PaymentIntentId) { + LiveSecrets.hold(PublicKey(intentId.bytes.toList()).base58()) + synchronized(lock) { intentIds[messageId] = intentId } + } + + private fun claim(): Boolean = synchronized(lock) { + if (busy) false else true.also { busy = true } + } + + private fun setRow(messageId: Long, row: SendBackRow) { + local.update { it + (messageId to row) } + } + + private fun setRowIfUnset(messageId: Long, row: SendBackRow) { + local.update { if (messageId in it) it else it + (messageId to row) } + } + + private fun presentInsufficientBalance() { + payer.presentInsufficientBalance(onAddMoney = ::presentDeposit) + } + + private fun presentDeposit() { + scope.launch { payer.depositRoute()?.let(openScreen) } + } + + private fun presentRateUnavailable() { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_staleRates), + message = resources.getString(R.string.error_description_staleRates), + ) + } + + /** A submit that failed, the return still not made (test case 15). */ + private fun presentFailed() { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_couldntSendBack), + message = resources.getString(R.string.error_description_couldntSendBack), + ) + } + + private companion object { + /** `Sent Cash`'s error when the payment had already been returned. */ + const val ALREADY_RETURNED = "Already Returned" + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt new file mode 100644 index 0000000000..06e02c1c3e --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/sendback/SendBackFlowTest.kt @@ -0,0 +1,441 @@ +package com.flipcash.app.messenger.internal.sendback + +import com.flipcash.analytics.SendContext +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.messenger.internal.payment.StartChattingPayer +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.CashMessageReturnStatus +import com.flipcash.services.models.chat.CashMessageReturnStatus.Status +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessageContent.Cash.PaymentReturn +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.models.SendBackRow +import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate +import com.getcode.manager.BottomBarManager +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.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import com.getcode.vendor.Base58 +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** Spec test cases 1–7, 10, 11, 13–15, 18 and 19. Case 12 is SendBackPaymentDelegateTest's. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SendBackFlowTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val payments = mockk(relaxed = true) + private val payer = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val token = mockk(relaxed = true) + private val owner = mockk(relaxed = true) + private val mint = mockk(relaxed = true) + private val scope = CoroutineScope(Dispatchers.Unconfined) + private val analytics = RecordingAnalytics() + + private val me: ID = listOf(1) + private val peer: ID = listOf(2) + private val chat = ChatId(ByteArray(32) { 3 }) + private val offeredId = 10L + private val amount = Fiat("5.00", CurrencyCode.USD) + private val intentBytes = ByteArray(32) { 7 } + private val intentId = PaymentIntentId(intentBytes) + + /** Quarks the return needs at today's rate, and quarks the viewer holds. */ + private val needed = 500L + private var held: Long? = 1_000L + + private fun pricedAt(quarks: Long) = mockk(relaxed = true) { + every { localFiat.underlyingTokenAmount.quarks } returns quarks + } + + private val priced = pricedAt(needed) + + private var statusCalls = 0 + private var statuses = ArrayDeque>() + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountId } returns me + every { userManager.accountCluster } returns owner + every { owner.withTimelockForToken(any()) } returns owner + every { tokenCoordinator.cachedToken(any()) } returns token + every { tokenCoordinator.heldQuarksForToken(any()) } answers { flowOf(held) } + coEvery { payments.price(any(), any()) } returns Result.success(priced) + every { resources.getString(R.string.error_title_staleRates) } returns RATE_UNAVAILABLE + every { resources.getString(R.string.error_title_couldntSendBack) } returns COULDNT_SEND_BACK + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) + } + + @After + fun tearDown() { + scope.cancel() + BottomBarManager.clear() + } + + private fun status(s: Status, id: PaymentIntentId? = intentId) = Result.success(CashMessageReturnStatus(s, id)) + + private fun answers(first: Result, then: Result? = null) { + statuses = ArrayDeque(listOfNotNull(first, then)) + } + + private fun flow() = SendBackFlow( + scope = scope, + payments = payments, + returnStatus = { _, _ -> + statusCalls++ + statuses.removeFirstOrNull() ?: Result.failure(IllegalStateException("no status")) + }, + payer = payer, + tokenCoordinator = tokenCoordinator, + userManager = userManager, + resources = resources, + analytics = analytics, + chatId = { chat }, + peerName = { "Sally" }, + openScreen = {}, + ) + + private fun cash( + receiver: ID? = me, + offer: Boolean = true, + returnOf: Long? = null, + ) = MessageContent.Cash( + intentId = listOf(9), + amount = amount, + mint = mint, + receiver = receiver, + paymentReturn = PaymentReturn( + offer = if (offer) PaymentReturn.Offer else null, + returnOf = returnOf?.let { PaymentReturn.ReturnOf(it) }, + ), + ) + + /** The peer's offered payment to me, as the transcript notes it. */ + private fun SendBackFlow.noteOffer() = note(offeredId, cash(), senderId = peer, isFromSelf = false) + + private fun SendBackFlow.available(): SendBackFlow = apply { + answers(status(Status.AVAILABLE)) + noteOffer() + } + + private fun sentCash(state: AnalyticsState, error: String?) = + TransferEvents.sentCash(state, amount.analytics, error, SendContext.SEND_BACK) + + // --- Rows (1–7) --- + + @Test + fun `1 the receiver of an available offer sees Send Back`() { + val f = flow().available() + + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(1, statusCalls) + } + + @Test + fun `2 the sender of an offer sees no row and makes no status call`() { + val f = flow() + answers(status(Status.AVAILABLE)) + + f.note(offeredId, cash(receiver = peer), senderId = me, isFromSelf = true) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `2 a viewer who is not the single receiver makes no status call`() { + val f = flow() + + f.note(offeredId, cash(receiver = listOf(5)), senderId = peer, isFromSelf = false) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `3 a cash message with no offer gets no row and no status call`() { + val f = flow() + + f.note(offeredId, cash(offer = false), senderId = peer, isFromSelf = false) + + assertNull(f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `4 a return already in the transcript marks the offer Sent Back without a status call`() { + val f = flow() + answers(status(Status.AVAILABLE)) + + // Newest first: the return is noted before the original it names. + f.note(20L, cash(receiver = peer, offer = false, returnOf = offeredId), senderId = me, isFromSelf = true) + f.noteOffer() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `5 status RETURNED marks the offer Sent Back`() { + val f = flow() + answers(status(Status.RETURNED, id = null)) + + f.noteOffer() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + } + + @Test + fun `6 the original sender sees Sent Back once a return names their message`() { + val f = flow() + + f.note(offeredId, cash(receiver = peer), senderId = me, isFromSelf = true) + f.note(20L, cash(receiver = me, offer = false, returnOf = offeredId), senderId = peer, isFromSelf = false) + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, statusCalls) + } + + @Test + fun `7 a failed status call leaves no row and shows no error`() { + val f = flow() + answers(Result.failure(IllegalStateException("offline"))) + + f.noteOffer() + + assertNull(f.rows.value[offeredId]) + assertEquals(0, BottomBarManager.messages.value.size) + } + + @Test + fun `the status check runs once per DM open however often the message is mapped`() { + val f = flow().available() + + f.noteOffer() + f.noteOffer() + + assertEquals(1, statusCalls) + } + + // --- Tap (10, 11) --- + + @Test + fun `10 a tap with enough balance opens the sheet naming the peer`() { + val f = flow().available() + + f.tap(offeredId) + + val sheet = assertNotNull(f.sheet.value) + assertEquals("Sally", sheet.recipientName) + assertEquals(amount, sheet.amount) + assertEquals(listOf(TransferEvents.sendBackTapped(hasBalance = true)), analytics.events) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + } + + @Test + fun `11 a tap with too little balance shows the modal instead of the sheet`() { + held = needed - 1 + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + verify(exactly = 1) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(listOf(TransferEvents.sendBackTapped(hasBalance = false)), analytics.events) + } + + // --- Balance that can't be determined --- + + private fun noFreshRate() { + coEvery { payments.price(any(), any()) } returns Result.failure(IllegalStateException("stale")) + } + + @Test + fun `a tap with no fresh rate shows Rate Unavailable and sends no Tapped event`() { + noFreshRate() + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `a tap before the balance has loaded shows Rate Unavailable and sends no Tapped event`() { + held = null + val f = flow().available() + + f.tap(offeredId) + + assertNull(f.sheet.value) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + verify(exactly = 0) { payer.presentInsufficientBalance(any(), any()) } + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `a swipe with no fresh rate closes the sheet with Rate Unavailable and pays nothing`() { + val f = flow().available() + f.tap(offeredId) + noFreshRate() + + f.confirm() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(listOf(RATE_UNAVAILABLE), BottomBarManager.messages.value.map { it.title }) + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + } + + // --- Submit (13–15, 19) --- + + private fun SendBackFlow.swipe(): SendBackFlow = apply { + tap(offeredId) + analytics.events.clear() + confirm() + } + + @Test + fun `13 a successful return marks the row Sent Back and sends Sent Cash Success`() { + val f = flow().available().swipe() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(listOf(sentCash(AnalyticsState.SUCCESS, null)), analytics.events) + coVerify(exactly = 1) { + payments.pay(chat, offeredId, peer, priced, token, owner, intentId) + } + // The id from the DM-open check is reused; no second status call. + assertEquals(1, statusCalls) + } + + @Test + fun `the swipe pays the amount its balance check priced, without pricing again`() { + val atTap = pricedAt(needed) + val atSwipe = pricedAt(needed + 1) + coEvery { payments.price(any(), any()) } returnsMany listOf(Result.success(atTap), Result.success(atSwipe)) + + flow().available().swipe() + + coVerify(exactly = 1) { payments.pay(chat, offeredId, peer, atSwipe, token, owner, intentId) } + // One price for the tap's check and one for the swipe's; none between the check and pay. + coVerify(exactly = 2) { payments.price(amount, token) } + } + + @Test + fun `14 a failed return the server reports RETURNED is Sent Back with no error`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + answers(status(Status.RETURNED, id = null)) + + f.swipe() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + assertEquals(0, BottomBarManager.messages.value.size) + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "Already Returned")), analytics.events) + } + + @Test + fun `15 a failed return still available goes back to Available with the error`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + answers(status(Status.AVAILABLE)) + + f.swipe() + + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + assertEquals(listOf(COULDNT_SEND_BACK), BottomBarManager.messages.value.map { it.title }) + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "boom")), analytics.events) + } + + @Test + fun `status already RETURNED at submit makes no payment and counts as Already Returned`() { + val f = flow() + // Available with no id: the submit has to ask again, and learns it was returned. + answers(status(Status.AVAILABLE, id = null), status(Status.RETURNED, id = null)) + f.noteOffer() + + f.swipe() + + assertEquals(SendBackRow.SentBack, f.rows.value[offeredId]) + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + assertEquals(listOf(sentCash(AnalyticsState.FAILURE, "Already Returned")), analytics.events) + } + + @Test + fun `19 a balance that drops while the sheet is open stops the swipe`() { + val f = flow().available() + f.tap(offeredId) + held = needed - 1 + + f.confirm() + + assertNull(f.sheet.value) + assertEquals(SendBackRow.Available, f.rows.value[offeredId]) + verify(exactly = 1) { payer.presentInsufficientBalance(any(), any()) } + coVerify(exactly = 0) { payments.pay(any(), any(), any(), any(), any(), any(), any()) } + } + + // --- 18: no intent id outside the payment --- + + @Test + fun `18 no event and no exposed state carries the intent id`() { + coEvery { payments.pay(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(IllegalStateException("boom")) + val f = flow().available() + f.tap(offeredId) + val sheet = f.sheet.value.toString() + answers(status(Status.AVAILABLE)) + f.confirm() + + val base58 = Base58.encode(intentBytes) + val forms = listOf(base58, intentBytes.toList().toString()) + val exposed = analytics.events.map { it.toString() } + sheet + f.rows.value.toString() + for (text in exposed) for (form in forms) assertFalse(form in text, "intent id in: $text") + } + + private companion object { + const val RATE_UNAVAILABLE = "Rate Unavailable" + const val COULDNT_SEND_BACK = "Couldn't Send Back" + } +} diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt index d50ea78f90..da8d9a4494 100644 --- a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/CashBubbleLinesTest.kt @@ -226,6 +226,30 @@ class CashBubbleLinesTest { ) } + // Send Back: a return of a DM's opening payment. + + private fun returnOf() = cash().copy( + paymentReturn = MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(7)), + ) + + @Test + fun `8 - the sender of a return reads that they sent it back`() { + assertEquals("You sent back" to "to Noah Parker", returnOf().read(isFromSelf = true, people = dm)) + } + + @Test + fun `9 - the receiver of a return reads who sent it back, to them`() { + assertEquals("Noah Parker sent back" to "to You", returnOf().read(isFromSelf = false, people = dm)) + } + + @Test + fun `an offered payment that is not a return keeps its DM lines`() { + val offered = cash().copy( + paymentReturn = MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer), + ) + assertEquals("You received" to "from Noah Parker", offered.read(isFromSelf = false, people = dm)) + } + // Tips. @Test diff --git a/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt new file mode 100644 index 0000000000..6b06853f48 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/androidHostTest/kotlin/com/flipcash/shared/chat/ui/SendBackBubbleScreenshotTest.kt @@ -0,0 +1,290 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.Dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SendBackRow +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * The Send Back frames of a DM's opening payment and its return (`docs/specs/send-back.md` in the + * orchestrator): the offered payment with each row, and the return as each side reads it. Checks the + * Sent Back footer's divider and spacing, and writes every frame to `build/screenshots/send_back_bubbles.png`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h2000dp-xhdpi") +class SendBackBubbleScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun cash(paymentReturn: MessageContent.Cash.PaymentReturn) = MessageContent.Cash( + intentId = listOf(0), + amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD), + mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa"), + tokenName = "Dollars", + paymentReturn = paymentReturn, + ) + + private val offered = cash(MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer)) + private val returned = cash( + MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(1)), + ) + private val dm = CashBubblePeople.Direct(other = "Alex Morgan") + + private fun bubble(content: MessageContent.Cash, isFromSelf: Boolean, sendBack: SendBackRow? = null) = + ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + cashPeople = dm, + sendBack = sendBack, + ) + + private val frames = listOf( + "S1 Available, receiver" to bubble(offered, isFromSelf = false, sendBack = SendBackRow.Available), + "S5 Sent Back, sender" to bubble(offered, isFromSelf = true, sendBack = SendBackRow.SentBack), + "S6 Sent Back, receiver" to bubble(offered, isFromSelf = false, sendBack = SendBackRow.SentBack), + "8 Return, its sender" to bubble(returned, isFromSelf = true), + "9 Return, its receiver" to bubble(returned, isFromSelf = false), + ) + + private var footerHeight = Dp.Unspecified + private var rowHeight = Dp.Unspecified + private var hairline = Dp.Unspecified + + private fun show(items: List>) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + footerHeight = CodeTheme.dimens.grid.x9 + rowHeight = CodeTheme.dimens.grid.x11 + hairline = CodeTheme.dimens.border + Column( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items.forEach { (name, item) -> + Text(text = name, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary) + Box(modifier = Modifier.testTag(name)) { + ContentBubble(item = item, position = BubblePosition.Solo, interactive = false) + } + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + } + + @Test + fun `only a Sent Back footer draws the divider`() { + show(frames) + // S5 and S6; not the Send Back row and not either return bubble. + composeRule.onAllNodesWithTag(SENT_BACK_DIVIDER_TAG, useUnmergedTree = true).assertCountEquals(2) + } + + private fun height(tag: String) = composeRule.onNodeWithTag(tag, useUnmergedTree = true) + .getUnclippedBoundsInRoot().let { it.bottom - it.top } + + private fun assertDp(expected: Dp, actual: Dp, what: String) = + assertEquals(what, expected.value, actual.value, 0.5f) + + @Test + fun `the last line has the same gap above the Sent Back divider as above the Send Back row`() { + show(frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + val lines = composeRule.onAllNodesWithText("from Alex Morgan", useUnmergedTree = true) + val aboveRow = composeRule.onNodeWithTag(SEND_BACK_ROW_TAG, useUnmergedTree = true) + .getUnclippedBoundsInRoot().top - lines[0].getUnclippedBoundsInRoot().bottom + val aboveDivider = composeRule.onNodeWithTag(SENT_BACK_DIVIDER_TAG, useUnmergedTree = true) + .getUnclippedBoundsInRoot().top - lines[1].getUnclippedBoundsInRoot().bottom + assertDp(aboveRow, aboveDivider, "gap above the divider") + } + + @Test + fun `the Sent Back footer sits below the bottom padding`() { + val bare = "offered, no row" to bubble(offered, isFromSelf = false) + show(listOf(bare) + frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + assertDp(footerHeight, height(SENT_BACK_FOOTER_TAG), "footer band") + // The bare bubble already ends in its bottom padding; the footer adds its hairline and band. + assertDp(height(bare.first) + hairline + footerHeight, height("S6 Sent Back, receiver"), "S6 over bare") + // Same body as S1, with the band in place of the white row. + assertDp( + height("S1 Available, receiver") - rowHeight + hairline + footerHeight, + height("S6 Sent Back, receiver"), + "S6 against S1", + ) + } + + @Test + fun `the return's receiver reads to You`() { + show(listOf(frames.last())) + composeRule.onNodeWithText("to You", useUnmergedTree = true).assertExists() + } + + private fun width(tag: String) = composeRule.onNodeWithTag(tag, useUnmergedTree = true) + .getUnclippedBoundsInRoot().let { it.right - it.left } + + @Test + fun `the Send Back row and the Sent Back footer never change the bubble's width`() { + val bare = "offered, no row" to bubble(offered, isFromSelf = false) + show(listOf(bare) + frames.filter { it.first.startsWith("S1") || it.first.startsWith("S6") }) + assertDp(width(bare.first), width("S1 Available, receiver"), "S1 width") + assertDp(width(bare.first), width("S6 Sent Back, receiver"), "S6 width") + } + + /** + * A bare offered bubble tagged `bare` beside one tagged `live` whose Send Back state and names + * the test changes after it has drawn, the way the transcript gets them when they load late. + */ + private fun showLive( + initial: SendBackRow?, + people: CashBubblePeople? = dm, + ): Pair, MutableState> { + val state = mutableStateOf(initial) + val names = mutableStateOf(people) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + footerHeight = CodeTheme.dimens.grid.x9 + rowHeight = CodeTheme.dimens.grid.x11 + hairline = CodeTheme.dimens.border + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.testTag("bare")) { + ContentBubble(item = bubble(offered, isFromSelf = false), position = BubblePosition.Solo, interactive = false) + } + Box(modifier = Modifier.testTag("live")) { + ContentBubble( + item = bubble(offered, isFromSelf = false, sendBack = state.value) + .copy(cashPeople = names.value), + position = BubblePosition.Solo, + interactive = false, + ) + } + } + } + } + composeRule.mainClock.advanceTimeByFrame() + return state to names + } + + /** + * Makes [change] once the live bubble has drawn and settled, and checks its height eases from + * where it stood to [settled] on a spring, never jumping there in a frame, at the bare width. + */ + private fun assertEases(change: () -> Unit, settled: () -> Dp) { + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + val before = height("live") + + composeRule.runOnUiThread(change) + // Applies the write so the next frames recompose with it; the clock does not move. + composeRule.waitForIdle() + // Long enough for the spring to have moved a few dp even on the smallest change, the row + // giving way to Sent Back, and short of it settling. + repeat(6) { composeRule.mainClock.advanceTimeByFrame() } + val mid = height("live") + val low = minOf(before.value, settled().value) + val high = maxOf(before.value, settled().value) + assertTrue( + "mid-animation height $mid should be between $before and ${settled()}", + mid.value > low + 0.5f && mid.value < high - 0.5f, + ) + assertDp(width("bare"), width("live"), "width while it moves") + + composeRule.mainClock.advanceTimeBy(3_000) + assertDp(settled(), height("live"), "settled") + assertDp(width("bare"), width("live"), "width once settled") + } + + private fun assertGrowsIn(arriving: SendBackRow, added: () -> Dp) { + val (state, _) = showLive(initial = null) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertDp(height("bare"), height("live"), "before it arrives") + assertEases(change = { state.value = arriving }, settled = { height("bare") + added() }) + } + + @Test + fun `a Sent Back footer that arrives late grows in`() = assertGrowsIn(SendBackRow.SentBack) { hairline + footerHeight } + + @Test + fun `a Send Back row that arrives late grows in`() = assertGrowsIn(SendBackRow.Available) { rowHeight } + + @Test + fun `a counterpart line that arrives late grows in`() { + val (_, names) = showLive(initial = null, people = null) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertTrue("no counterpart yet, so shorter than the named bubble", height("live") < height("bare")) + assertEases(change = { names.value = dm }, settled = { height("bare") }) + } + + @Test + fun `the Send Back row giving way to Sent Back eases`() { + val (state, _) = showLive(initial = SendBackRow.Available) + repeat(5) { composeRule.mainClock.advanceTimeByFrame() } + assertDp(height("bare") + rowHeight, height("live"), "row drawn") + assertEases( + change = { state.value = SendBackRow.SentBack }, + settled = { height("bare") + hairline + footerHeight }, + ) + } + + @Test + fun `a footer known when the bubble first draws is there at full height`() { + showLive(initial = SendBackRow.SentBack) + assertDp(height("bare") + hairline + footerHeight, height("live"), "first frame") + } + + @Test + fun rendersEveryFrame() { + show(frames) + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "send_back_bubbles.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} diff --git a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 0f49a3a3df..909becf647 100644 --- a/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/androidMain/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -18,8 +18,14 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.runtime.key +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.animateContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf @@ -57,6 +63,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.flipcash.core.resources.Res +import com.flipcash.core.resources.action_sendBack +import com.flipcash.core.resources.label_sentBack import com.flipcash.core.resources.label_edited import com.flipcash.core.resources.label_groupSendTotal import com.flipcash.core.resources.label_messageDeleted @@ -79,6 +87,9 @@ import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SendBackRow +import com.getcode.ui.theme.CodeCircularProgressIndicator +import androidx.compose.foundation.clickable import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.ui.media.MediaMessageBubble import com.flipcash.shared.chat.ui.media.photoBody @@ -259,6 +270,12 @@ fun ContentBubble( onLongClick = onLongClick?.takeIf { interactive }, onDoubleClick = onDoubleClick?.takeIf { interactive }, attention = attention, + sendBack = item.sendBack, + onSendBack = if (interactive) { + { actionHandler(ChatAction.SendBack(item.messageId)) } + } else { + null + }, ) // A reply is a text bubble with a citation above the body. Routing it through @@ -436,6 +453,15 @@ private const val EDITED_MARKER_SLOT = "edited-marker" internal const val REPLY_QUOTE_TAG = "bubble_reply_quote" +/** The hairline across the top of a cash bubble's Sent Back footer. */ +internal const val SENT_BACK_DIVIDER_TAG = "bubble_sent_back_divider" + +/** The Sent Back footer's band, under its divider. */ +internal const val SENT_BACK_FOOTER_TAG = "bubble_sent_back_footer" + +/** The white Send Back row along a cash bubble's bottom edge. */ +internal const val SEND_BACK_ROW_TAG = "bubble_send_back_row" + /** * The citation over the body, with the narrower of the two stretched to the width of the wider. * @@ -929,7 +955,15 @@ private fun CashBubble( onDoubleClick: (() -> Unit)?, modifier: Modifier = Modifier, attention: () -> Float = { 0f }, + sendBack: SendBackRow? = null, + onSendBack: (() -> Unit)? = null, ) { + // A Send Back row or Sent Back footer spans the bubble edge to edge along its bottom, so the + // bubble draws no inset of its own and the lines above carry it on every side, row or no row. + // Both sit below the bottom padding, so the last line has the same gap above either. Keeping the + // inset inside the animated column means a row arriving only adds its own height. + val insetH = BubbleDefaults.paddingHorizontal + val insetV = BubbleDefaults.paddingVertical Bubble( isFromSelf = isFromSelf, position = position, @@ -940,6 +974,8 @@ private fun CashBubble( onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, + horizontalPadding = Dp.Hairline, + verticalPadding = Dp.Hairline, ) { // The same number the bill's "You received" sheet reads: a USDF payment converts from the // USD it settled at, anything else from today's rates. @@ -948,11 +984,18 @@ private fun CashBubble( val secondary = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Medium) val amountStyle = CodeTheme.typography.displayMedium + // Any line that lands after the bubble has drawn (the counterpart once names resolve, the + // token, the Send Back row or footer, the row giving way to Sent Back) moves the bubble's + // height on the transcript's reflow spring. The first measure is taken as is, so what is + // known when the bubble first draws is there at full height. The bubble's width is fixed, + // so only the height moves. + Column(modifier = Modifier.fillMaxWidth().animateContentSize(ChatAnimations.reflowIntSize)) { // The gaps use the static grid, like the bubble's own padding, so each spec value maps // down to the same 5dp step at every screen width. Column( modifier = Modifier .fillMaxWidth() + .padding(horizontal = insetH, vertical = insetV) .padding(vertical = CodeTheme.dimens.staticGrid.x1), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -1064,6 +1107,104 @@ private fun CashBubble( overflow = TextOverflow.MiddleEllipsis, ) } + + } + + SendBackFooter(sendBack = sendBack, onSendBack = onSendBack) + } + } +} + +/** + * The Send Back row or Sent Back footer along a cash bubble's bottom edge, or nothing. + * + * Present when the bubble first draws, it is drawn as is. Arriving or changing after that (the + * return state loads after the transcript does, or a return lands), it fades in on the reflow + * spring while the bubble's [animateContentSize] grows or shrinks the room for it, so the size is + * animated once, by the bubble. + */ +@Composable +private fun SendBackFooter( + sendBack: SendBackRow?, + onSendBack: (() -> Unit)?, +) { + val drawn = remember { SendBackDrawn(first = sendBack) } + if (sendBack != drawn.first) drawn.changed = true + key(sendBack) { + val alpha = remember { Animatable(if (drawn.changed) 0f else 1f) } + LaunchedEffect(Unit) { alpha.animateTo(1f, ChatAnimations.reflowFloat) } + Column(modifier = Modifier.graphicsLayer { this.alpha = alpha.value }) { + when (sendBack) { + SendBackRow.SentBack -> { + // The divider every sheet and card in the app draws between sections, edge to + // edge: the bubble drops its horizontal inset for this footer. + HorizontalDivider( + modifier = Modifier.testTag(SENT_BACK_DIVIDER_TAG), + thickness = CodeTheme.dimens.border, + color = CodeTheme.colors.divider, + ) + Box( + modifier = Modifier + .testTag(SENT_BACK_FOOTER_TAG) + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x9), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.label_sentBack), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + SendBackRow.Available, SendBackRow.Sending -> SendBackActionRow( + sending = sendBack == SendBackRow.Sending, + onClick = onSendBack, + ) + null -> Unit + } + } + } +} + +/** Not state: [SendBackFooter] writes [changed] while composing, only when its Send Back state moved off [first]. */ +private class SendBackDrawn(val first: SendBackRow?) { + var changed = false +} + +/** + * The white row along a cash bubble's bottom edge that offers Send Back, or holds a spinner while + * the return is submitted. Not clickable while sending, or when there is nothing to call. + */ +@Composable +private fun SendBackActionRow( + sending: Boolean, + onClick: (() -> Unit)?, +) { + Box( + modifier = Modifier + .testTag(SEND_BACK_ROW_TAG) + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x11) + .background(CodeTheme.colors.textMain) + .then( + if (!sending && onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ), + contentAlignment = Alignment.Center, + ) { + if (sending) { + CodeCircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.grid.x4), + color = CodeTheme.colors.background, + strokeWidth = CodeTheme.dimens.thickBorder, + ) + } else { + Text( + text = stringResource(Res.string.action_sendBack), + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.background, + ) } } } @@ -1353,6 +1494,7 @@ private fun PreviewCashBubble( isFromSelf: Boolean, viewerPaid: Boolean = false, people: CashBubblePeople?, + sendBack: SendBackRow? = null, ) { CompositionLocalProvider( LocalExchange provides ExchangeStub() @@ -1367,10 +1509,34 @@ private fun PreviewCashBubble( onClick = null, onLongClick = null, onDoubleClick = null, + sendBack = sendBack, + onSendBack = {}, ) } } +/** Send Back frames S1, S2, S5 and S6, and the return bubble both ways. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_CashBubble_SendBack() { + val offered = previewCash(5.0).copy( + paymentReturn = MessageContent.Cash.PaymentReturn(offer = MessageContent.Cash.PaymentReturn.Offer), + ) + val returned = previewCash(5.0).copy( + paymentReturn = MessageContent.Cash.PaymentReturn(returnOf = MessageContent.Cash.PaymentReturn.ReturnOf(1)), + ) + val fred = CashBubblePeople.Direct("Fred Wilson") + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.Available) + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.Sending) + PreviewCashBubble(offered, isFromSelf = false, people = fred, sendBack = SendBackRow.SentBack) + PreviewCashBubble(offered, isFromSelf = true, people = fred, sendBack = SendBackRow.SentBack) + PreviewCashBubble(returned, isFromSelf = true, people = fred) + PreviewCashBubble(returned, isFromSelf = false, people = fred) + } +} + /** Frame C. */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index bb085882de..9fe9f196ff 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -18,6 +18,9 @@ sealed interface ChatAction { /** Opens who a group send ([ChatListItem.ContentBubble] with a multi-receiver cash) paid. */ data class ViewGroupSend(val bubble: ChatListItem.ContentBubble) : ChatAction + /** The receiver tapped Send Back under the opening payment in [messageId]. */ + data class SendBack(val messageId: Long) : ChatAction + /** * The group gate's Buy: opens the buy screen for [mint] straight away, so the reader comes back * to the gate once the buy lands. The gate sends [AddCash] instead when nothing they hold can diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 145504d784..f5a79506df 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -214,6 +214,8 @@ sealed interface ChatListItem { * resolved them, which leaves the bubble its caption alone. */ val cashPeople: CashBubblePeople? = null, + /** The Send Back row under a cash bubble, or null for none. */ + val sendBack: SendBackRow? = null, ) : TranscriptMessage { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt new file mode 100644 index 0000000000..dcb7c40527 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/models/SendBackRow.kt @@ -0,0 +1,19 @@ +package com.flipcash.shared.chat.models + +/** + * What a cash bubble shows under its lines for Send Back: returning a DM's opening payment to its + * sender. Absent (null on the bubble) draws the bubble as before. + * + * Only the payment's receiver ever sees [Available] or [Sending]; its sender sees [SentBack] once + * the return is in the transcript. + */ +enum class SendBackRow { + /** The receiver can send the payment back. A tappable "Send Back" row. */ + Available, + + /** The return is being submitted. The row holds a spinner and takes no taps. */ + Sending, + + /** The payment has been returned. A "Sent Back" footer. */ + SentBack, +} diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt index 2dcd195663..9e2ee143e0 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/CashBubbleLines.kt @@ -5,15 +5,18 @@ import androidx.compose.runtime.Immutable import com.flipcash.core.resources.Res import com.flipcash.core.resources.label_cashFrom import com.flipcash.core.resources.label_cashSomeone +import com.flipcash.core.resources.label_cashReturnToYou import com.flipcash.core.resources.label_groupSendEach import com.flipcash.core.resources.label_groupSendEachApprox import com.flipcash.core.resources.label_groupSendRecipients import com.flipcash.core.resources.label_groupSendTo import com.flipcash.core.resources.label_groupSendToPeople import com.flipcash.core.resources.subtitle_senderSent +import com.flipcash.core.resources.subtitle_senderSentBack import com.flipcash.core.resources.subtitle_youAndOthersReceived import com.flipcash.core.resources.subtitle_youReceived import com.flipcash.core.resources.subtitle_youSent +import com.flipcash.core.resources.subtitle_youSentBack import com.flipcash.core.resources.subtitle_youTipped import org.jetbrains.compose.resources.PluralStringResource import org.jetbrains.compose.resources.StringResource @@ -55,6 +58,12 @@ sealed interface CashCaption { /** A group member who was not paid reads who sent it. */ data class SenderSent(val sender: String?) : CashCaption + + /** The viewer sent this payment back: it returns an opening payment they were paid. */ + data object YouSentBack : CashCaption + + /** [sender] sent this payment back to the viewer. */ + data class SenderSentBack(val sender: String?) : CashCaption } /** The line at the foot of a cash bubble: who it went to, or who it came from. */ @@ -69,6 +78,9 @@ sealed interface CashCounterpart { data class ToPeople(val count: Int) : CashCounterpart data class From(val name: String?) : CashCounterpart + + /** A returned payment, read by the person it was returned to. */ + data object ToYou : CashCounterpart } @Immutable @@ -97,6 +109,17 @@ fun MessageContent.Cash.bubbleLines( MessageContent.Cash.Action.TIPPED -> CashCaption.YouTipped MessageContent.Cash.Action.SENT -> CashCaption.YouSent } + // A return names itself on both sides, and reads "to You" rather than "from" for its receiver: + // the money is going back, not arriving (Send Back spec, "The return message"). + if (paymentReturn?.returnOf != null && people !is CashBubblePeople.Group) { + val other = (people as? CashBubblePeople.Direct)?.other + return if (isFromSelf) { + CashBubbleLines(CashCaption.YouSentBack, people?.let { CashCounterpart.To(other) }) + } else { + CashBubbleLines(CashCaption.SenderSentBack(other), people?.let { CashCounterpart.ToYou }) + } + } + // The verb splits only on the sending side: a received tip and a received payment are the same // money arriving. val captionOnly = CashBubbleLines(if (isFromSelf) own else CashCaption.YouReceived, counterpart = null) @@ -212,6 +235,9 @@ internal inline fun CashCaption.resolve( is CashCaption.YouAndOthersReceived -> plural(Res.plurals.subtitle_youAndOthersReceived, others, arrayOf(others)) is CashCaption.SenderSent -> string(Res.string.subtitle_senderSent, arrayOf(sender.orSomeone(string))) + CashCaption.YouSentBack -> string(Res.string.subtitle_youSentBack, emptyArray()) + is CashCaption.SenderSentBack -> + string(Res.string.subtitle_senderSentBack, arrayOf(sender.orSomeone(string))) } internal inline fun CashCounterpart.resolve( @@ -225,6 +251,7 @@ internal inline fun CashCounterpart.resolve( ) is CashCounterpart.ToPeople -> plural(Res.plurals.label_groupSendToPeople, count, arrayOf(count)) is CashCounterpart.From -> string(Res.string.label_cashFrom, arrayOf(name.orSomeone(string))) + CashCounterpart.ToYou -> string(Res.string.label_cashReturnToYou, emptyArray()) } private inline fun String?.orSomeone(string: (StringResource, Array) -> String): String = diff --git a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 7d0c5c43fe..c8404ccb28 100644 --- a/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/commonMain/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -36,9 +36,9 @@ object ChatAnimations { // Rows resizing in place (a receipt moving on, a reaction): glides to the new layout and never // bounces. Matches iOS reflow: .spring(duration: 0.35, bounce: 0). private const val ReflowStiffness = 322f - private val reflowIntSize: SpringSpec = + internal val reflowIntSize: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) - private val reflowFloat: SpringSpec = + internal val reflowFloat: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) val reflowDp: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = ReflowStiffness) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt new file mode 100644 index 0000000000..74d6144d34 --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegate.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.payments.sendback + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildReturnPaymentMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.flipcash.shared.payments.SharedStatePayment +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.base58 +import com.getcode.utils.LiveSecrets +import com.getcode.utils.TraceType +import com.getcode.utils.maskedSuffix +import com.getcode.utils.trace +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Sends back a DM's opening payment: one public payment of the original's fiat amount, in its + * currency and mint, to the person who paid it, carrying `ChatMetadata.ReturnPayment` and submitted + * under the intent id `GetCashMessageReturnStatus` issued for that message. + * + * The amount is priced at today's rate, so the token count may differ from the original's. + * + * No UI, no status calls, and no balance check: those are the caller's. + */ +@Singleton +class SendBackPaymentDelegate @Inject constructor( + resolverController: ResolverController, + transactionController: TransactionController, + tokenCoordinator: TokenCoordinator, + private val verifiedFiatCalculator: VerifiedFiatCalculator, + private val exchange: Exchange, +) { + + private val payment = SharedStatePayment( + resolverController, transactionController, tokenCoordinator, verifiedFiatCalculator, + ) + + /** Where the pay outcome is logged; tests swap it to read the line. */ + @androidx.annotation.VisibleForTesting + internal var log: (message: String, type: TraceType) -> Unit = { message, type -> + trace(tag = "SendBack", message = message, type = type) + } + + /** + * [amount] in [token] at today's rate, uncapped by any balance, so its token quarks + * (`localFiat.underlyingTokenAmount.quarks`) are what the return needs. + */ + suspend fun price(amount: Fiat, token: Token): Result { + val rate = exchange.rateFor(amount.currencyCode) + ?: return Result.failure(ComputeVerifiedFiatError.StaleRate()) + return verifiedFiatCalculator.compute(amount = amount, token = token, balance = null, rate = rate) + } + + /** + * Pays [priced] in [token] from [source] to [payee], the original payment's sender, as the + * return of [messageId] in the DM [chatId], under [intentId]. + * + * [priced] is [price]'s answer for the balance check just made, sent as is: pricing again here + * could read a newer rate and pay a different token count from the one checked. + * + * The id is only ever logged masked, on either outcome: the spec keeps it out of every log. + */ + suspend fun pay( + chatId: ChatId, + messageId: Long, + payee: ID, + priced: VerifiedFiat, + token: Token, + source: AccountCluster, + intentId: PaymentIntentId, + ): Result { + val key = PublicKey(intentId.bytes.toList()) + val masked = key.base58().also(LiveSecrets::hold).maskedSuffix() + return payment.pay( + payees = listOf(payee), + priced = listOf(priced), + token = token, + source = source, + appMetadata = buildReturnPaymentMetadata(chatId, messageId), + intentId = key, + ).onSuccess { + log("sent back under intent $masked", TraceType.Log) + }.onFailure { + log("send back failed under intent $masked", TraceType.Error) + } + } +} diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt new file mode 100644 index 0000000000..1db0079a7e --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/sendback/SendBackPaymentDelegateTest.kt @@ -0,0 +1,159 @@ +package com.flipcash.shared.payments.sendback + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.buildReturnPaymentMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentIntentId +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.internal.manager.VerifiedState +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.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.LiveSecrets +import com.getcode.vendor.Base58 +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SendBackPaymentDelegateTest { + + private val resolverController = mockk() + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val calculator = mockk() + private val exchange = mockk(relaxed = true) + + private val delegate = SendBackPaymentDelegate( + resolverController, transactionController, tokenCoordinator, calculator, exchange, + ) + + private val chatId = ChatId(ByteArray(32) { 9 }) + private val messageId = 41L + private val originalSender: ID = List(16) { 1 } + private val senderOwner = mockk() + private val intentBytes = ByteArray(32) { 7 } + private val token = mockk(relaxed = true) + private val source = mockk(relaxed = true) + private val state = mockk() + private val eur = mockk() + private val original = Fiat("5.00", CurrencyCode.EUR) + private val priced = VerifiedFiat(mockk(relaxed = true), state) + + init { + every { exchange.rateFor(CurrencyCode.EUR) } returns eur + coEvery { resolverController.resolve(userId = originalSender) } returns Result.success(senderOwner) + coEvery { calculator.compute(any(), any(), any(), any(), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + } + + private suspend fun pay() = delegate.pay( + chatId = chatId, + messageId = messageId, + payee = originalSender, + priced = priced, + token = token, + source = source, + intentId = PaymentIntentId(intentBytes), + ) + + @Test + fun `12 the return pays the original's amount to its sender under the server's intent id`() = runTest { + val sent = slot() + val metadata = slot() + val intentId = slot() + coEvery { + transactionController.directTransfer(capture(sent), token, source, senderOwner, capture(metadata), any(), capture(intentId)) + } returns Result.success(mockk()) + + assertTrue(pay().isSuccess) + + assertSame(priced, sent.captured) + assertContentEquals(intentBytes, intentId.captured.byteArray) + assertContentEquals(buildReturnPaymentMetadata(chatId, messageId), metadata.captured) + } + + @Test + fun `12 the return is priced in the original's amount and currency at today's rate`() = runTest { + val amount = slot() + val rate = slot() + coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + + assertTrue(delegate.price(original, token).isSuccess) + + assertEquals(original, amount.captured) + assertSame(eur, rate.captured) + } + + @Test + fun `pay sends the amount it is given and does not price again`() = runTest { + coEvery { + transactionController.directTransfer(any(), token, source, senderOwner, any(), any(), any()) + } returns Result.success(mockk()) + + pay() + + coVerify(exactly = 0) { calculator.compute(any(), any(), any(), any(), any()) } + verify(exactly = 0) { exchange.rateFor(any()) } + } + + @Test + fun `price is not capped by any balance, so its quarks are what the return needs`() = runTest { + val balance = slot() + coEvery { calculator.compute(any(), any(), captureNullable(balance), any(), any()) } returns + Result.success(VerifiedFiat(mockk(relaxed = true), state)) + + assertTrue(delegate.price(original, token).isSuccess) + assertEquals(null, balance.captured) + } + + private val intentBase58 = Base58.encode(intentBytes) + + private suspend fun logLinesFor(outcome: Result): List { + coEvery { + transactionController.directTransfer(any(), token, source, senderOwner, any(), any(), any()) + } returns outcome.map { mockk() } + val lines = mutableListOf() + delegate.log = { message, _ -> lines += message } + pay() + return lines + } + + @Test + fun `18 a successful return logs the intent id masked only`() = runTest { + val line = logLinesFor(Result.success(Unit)).single() + + assertFalse(intentBase58 in line, line) + assertTrue(line.endsWith("…${intentBase58.takeLast(6)}"), line) + assertTrue(LiveSecrets.isLive(intentBase58)) + LiveSecrets.release(intentBase58) + } + + @Test + fun `18 a failed return logs the intent id masked only`() = runTest { + val line = logLinesFor(Result.failure(IllegalStateException("boom"))).single() + + assertFalse(intentBase58 in line, line) + assertTrue(LiveSecrets.isLive(intentBase58)) + LiveSecrets.release(intentBase58) + } +} diff --git a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt index 75f1e6952a..fc4f036355 100644 --- a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -11,6 +11,7 @@ import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -122,6 +123,20 @@ class TransactionDetailsMapperTest { holderMetrics = HolderMetrics.None, ) + @Test + fun `a send-back is a send or receive whatever the verb says`() { + val returned = ReturnedPayment(chatId = ChatId(ByteArray(32) { 4 }), messageId = 11L) + + assertEquals( + TransactionKind.Sent, + map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned), text = "Tipped").kind, + ) + assertEquals( + TransactionKind.Received, + map(MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned), text = "Tip").kind, + ) + } + @Test fun `the verb separates a tip from a plain send`() { val meta = MessageMetadata.DirectlySentCrypto(userId = knownUserId) diff --git a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index 91131e1b30..9a54b4d037 100644 --- a/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/androidHostTest/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -13,6 +13,10 @@ import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.ReturnedPayment +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import kotlinx.coroutines.test.runTest import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -39,6 +43,8 @@ class TransactionItemMapperTest { .stub(R.string.title_activity_tipFrom, "Tip from %1\$s") .stub(R.string.title_activity_sentTo, "Sent to %1\$s") .stub(R.string.title_activity_receivedFrom, "Received from %1\$s") + .stub(R.string.title_activity_sentBackTo, "Sent back to %1\$s") + .stub(R.string.title_activity_sentBack, "%1\$s sent back") .stub(R.string.title_activity_convert, "%1\$s → %2\$s") .stub(R.string.title_activity_boostedFor, "Boosted %1\$s for %2\$s") .stub(R.string.title_activity_boostFrom, "Boost %1\$s from %2\$s") @@ -555,4 +561,67 @@ class TransactionItemMapperTest { assertEquals(TransactionAvatar.TokenIcon(usdfToken()), item.avatar) assertEquals(msg.amount, item.amount) } + + // --- Send back (spec test cases 16 and 17) --- + + private val dmId = ChatId(ByteArray(32) { 4 }) + private val returned = ReturnedPayment(chatId = dmId, messageId = 11L) + + private fun returnItem(metadata: MessageMetadata, profiles: Map = cached) = + mapper.map( + ActivityFeedMessageWithToken(feedMessage(metadata).copy(text = "Sent"), token = null) to profiles + ) + + @Test + fun `16 a sent return reads Sent back to the peer and opens the DM`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals("Sent back to Sally The Streamer", item.title) + assertEquals(dmId, item.returnChatId) + assertEquals( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmId)), + TransactionRouter.forChats { true }.routeFor(item), + ) + } + + @Test + fun `17 a received return reads the peer sent back and opens the DM`() = runTest { + val item = returnItem(MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals("Sally The Streamer sent back", item.title) + assertEquals( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmId)), + TransactionRouter.forChats { true }.routeFor(item), + ) + } + + @Test + fun `a return with an unresolved peer keeps the server title`() { + val item = returnItem( + MessageMetadata.ReceivedCrypto(userId = knownUserId, returnedPayment = returned), + profiles = emptyMap(), + ) + + assertEquals("Sent", item.title) + } + + @Test + fun `a return whose DM is gone opens its details`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId, returnedPayment = returned)) + + assertEquals( + AppRoute.Sheets.TransactionDetails(item.messageId), + TransactionRouter.forChats { false }.routeFor(item), + ) + } + + @Test + fun `a row with no return opens its details without asking for the chat`() = runTest { + val item = returnItem(MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + + assertEquals( + AppRoute.Sheets.TransactionDetails(item.messageId), + TransactionRouter.forChats { error("no chat lookup expected") }.routeFor(item), + ) + } } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt index d9724f4b7b..76baf06910 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt +++ b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryScreen.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.transactionhistory +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -35,6 +37,7 @@ import dev.chrisbanes.haze.rememberHazeState fun ActivityHistoryScreen() { val navigator = LocalCodeNavigator.current val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() val items = viewModel.transactions.collectAsLazyPagingItems() val listState = rememberLazyListState() @@ -73,7 +76,7 @@ fun ActivityHistoryScreen() { ActivityFeedRow( item = item, modifier = Modifier.fillMaxWidth(), - onClick = { navigator.push(AppRoute.Sheets.TransactionDetails(item.messageId)) }, + onClick = { scope.launch { navigator.push(viewModel.routeFor(item)) } }, ) } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt index 70f364f601..0756e4c194 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt +++ b/apps/flipcash/shared/transaction-history/src/androidMain/kotlin/com/flipcash/shared/transactionhistory/ActivityHistoryViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.transactionhistory +import com.flipcash.app.core.AppRoute import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -16,7 +17,10 @@ import javax.inject.Inject @HiltViewModel class ActivityHistoryViewModel @Inject constructor( feedCoordinator: ActivityFeedCoordinator, + private val router: TransactionRouter, ) : ViewModel() { + suspend fun routeFor(item: TransactionListItem): AppRoute = router.routeFor(item) + val transactions: Flow> = feedCoordinator.recentTransactions().cachedIn(viewModelScope) } diff --git a/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml index a786d72cd5..c390eb4bae 100644 --- a/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/androidMain/res/values/strings.xml @@ -6,6 +6,8 @@ Sent to %1$s Received from %1$s + Sent back to %1$s + %1$s sent back Boosted %1$s for %2$s Boost %1$s from %2$s diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt index d58bde92fb..4d47e0f942 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.transactionhistory +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.UserProfile import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat @@ -59,4 +60,6 @@ data class TransactionListItem( val amount: LocalFiat?, // message.amount, or null if non-financial val fee: Fiat?, // converts only: what the swap cost, shown under the amount val canCancel: Boolean, + // The DM a send-back was paid in, so tapping the row opens that chat (see [TransactionRouter]). + val returnChatId: ChatId? = null, ) diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt new file mode 100644 index 0000000000..9c2afdf9aa --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/TransactionRouter.kt @@ -0,0 +1,34 @@ +package com.flipcash.shared.transactionhistory + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.services.models.chat.ChatId + +/** + * Where tapping an activity row leads. + * + * Most rows open their details. A send-back opens the DM it was paid in, falling back to the + * details when that DM can't be opened: the server no longer has it, or it can't be reached right + * now. Without that check a missing DM would open as an empty chat. + * + * The chat lookup is passed in rather than read here because the chat module already depends on + * this one; the app binds it (see `TransactionRouterModule`). + */ +fun interface TransactionRouter { + suspend fun routeFor(item: TransactionListItem): AppRoute + + companion object { + /** Every row opens its details. For callers with no chat to open. */ + val DetailsOnly = TransactionRouter { item -> AppRoute.Sheets.TransactionDetails(item.messageId) } + + /** Opens a send-back's DM when [canOpen] says it exists, else the row's details. */ + fun forChats(canOpen: suspend (ChatId) -> Boolean) = TransactionRouter { item -> + val chatId = item.returnChatId + if (chatId != null && canOpen(chatId)) { + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId)) + } else { + AppRoute.Sheets.TransactionDetails(item.messageId) + } + } + } +} diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index ff8557b56f..8cd9332743 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -162,13 +162,16 @@ internal class TransactionDetailsMapper @Inject constructor( */ private fun kindOf(meta: MessageMetadata?, text: String): TransactionKind = when (meta) { is MessageMetadata.DirectlySentCrypto -> when { + // A send-back always names a DM peer; the verb heuristics below must not reclassify it. + meta.returnedPayment != null -> TransactionKind.Sent meta.boostedReaction != null -> TransactionKind.Boosted isUnidentifiedBill(meta) -> TransactionKind.GaveCash text.isTipVerb() -> TransactionKind.Tipped else -> TransactionKind.Sent } is MessageMetadata.ReceivedCrypto -> - if (meta.boostedReaction != null) TransactionKind.BoostReceived + if (meta.returnedPayment != null) TransactionKind.Received + else if (meta.boostedReaction != null) TransactionKind.BoostReceived else if (isUnidentifiedBill(meta)) TransactionKind.ReceivedCash else TransactionKind.Received is MessageMetadata.IndirectlySentCrypto -> TransactionKind.SentCashLink is MessageMetadata.WithdrewCrypto -> TransactionKind.Withdraw diff --git a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 10a0ee7b3e..79dbc9d4ae 100644 --- a/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/commonMain/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -4,12 +4,15 @@ import com.flipcash.shared.transactionhistory.generated.resources.Res import com.flipcash.shared.transactionhistory.generated.resources.title_activity_convert import com.flipcash.shared.transactionhistory.generated.resources.title_activity_receivedFrom import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentTo +import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentBack +import com.flipcash.shared.transactionhistory.generated.resources.title_activity_sentBackTo import com.flipcash.shared.transactionhistory.generated.resources.title_activity_tipFrom import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.TransactionAvatar import com.flipcash.shared.transactionhistory.TransactionListItem @@ -69,6 +72,7 @@ internal class TransactionItemMapper @Inject constructor( messageId = msg.id, title = convertTitle(resources, source) ?: boostTitle(resources, meta, counterparty, source.boostChatName) + ?: returnTitle(resources, meta, counterparty) ?: resolveTitle(resources, meta, msg.text, msg.textSubstitutions, counterparty, profiles), timestamp = msg.timestamp, avatar = avatar, @@ -76,6 +80,7 @@ internal class TransactionItemMapper @Inject constructor( amount = msg.amount, fee = convert?.fee, canCancel = (meta as? MessageMetadata.IndirectlySentCrypto)?.canCancel == true, + returnChatId = returnedPaymentOf(meta)?.chatId, ) } } @@ -134,6 +139,30 @@ internal fun boostedReactionOf(meta: MessageMetadata?): BoostedReaction? = when else -> null } +/** + * The title of a payment that sent back a DM's opening payment, or null when [meta] carries no + * return or the counterparty's name is not known yet, so the row falls back to the server's title. + */ +private fun returnTitle( + resources: ResourceHelper, + meta: MessageMetadata?, + counterparty: UserProfile?, +): String? { + if (returnedPaymentOf(meta) == null) return null + val name = counterparty?.displayName?.takeIf { it.isNotBlank() } ?: return null + return when (meta) { + is MessageMetadata.DirectlySentCrypto -> resources.getString(Res.string.title_activity_sentBackTo, name) + is MessageMetadata.ReceivedCrypto -> resources.getString(Res.string.title_activity_sentBack, name) + else -> null + } +} + +internal fun returnedPaymentOf(meta: MessageMetadata?): ReturnedPayment? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.returnedPayment + is MessageMetadata.ReceivedCrypto -> meta.returnedPayment + else -> null +} + internal fun requestedPaymentOf(meta: MessageMetadata?): RequestedPayment? = when (meta) { is MessageMetadata.DirectlySentCrypto -> meta.requestedPayment is MessageMetadata.ReceivedCrypto -> meta.requestedPayment