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