Skip to content

feat(chat): send back a DM's opening payment - #1220

Merged
bmc08gt merged 10 commits into
mainfrom
feat/chat-send-back
Nov 4, 2026
Merged

bmc08gt merged 10 commits into
mainfrom
feat/chat-send-back

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

The receiver of a DM's opening payment can now send it back, once, from the bubble. The original sender gets the same fiat amount back in the same currency and mint, priced at today's rate, and both sides see the original marked "Sent Back".

Spec: orchestrator docs/specs/send-back.md (orchestrator PR #111, 44c557f). Contract: flipcash2-client-protocol 0.25.0, already pinned here by #1218. This PR doesn't touch the pin or generated code. Design: section node 13296:476, frames S1–S3, S5, S6.

Shared-core

flipcash-shared-core-spm is pinned to 0.20.0, which contains the Send Back catalogue change from code-payments/code-android-app#1952. The last commit sends Send Back: Tapped and Sent Cash with the Send Back context against it.

What changed

Model and store. ConversationMessage carries CashReturn from CashContent.payment_return, persisted in two new conversation_message columns, so a reopened DM knows a payment was returned without asking the server. Activity carries returnedPayment in a side table beside requestedPayment. Schema 64 → 65; the store rebuilds from sync as it does for any bump.

The spec says to add CashReturn to ChatCashContent. That type is the rendered model, and the store persists ConversationMessage, so the raw CashReturn lives on ConversationMessage and ChatCashContent gets the derived row state (sendBack: .available / .sending / .sentBack).

Row state. SendBackRow.resolve is a pure function of the message, the viewer, the set of message IDs some loaded message's return_of names, and the controller's local state:

  • Receiver, offer set: hidden until the status is known, then Available. Sent Back once a loaded message returns it or the status says RETURNED.
  • Original sender: Sent Back once a loaded message returns it; otherwise no row. The sender never calls GetCashMessageReturnStatus.
  • No offer: no row and no status call. Chat type and "first message" aren't checked.

SendBackController. It lives on ConversationLoadCoordinator for one DM open. It runs the status check once for the offered message when no loaded message already returns it, and keeps the server's payment_intent_id in memory only. A failed check leaves the row hidden and shows nothing; the next open asks again.

Tap → balance check → sheet or modal. On tap, it prices the original's nativeAmount at today's rate in its currency and mint, and compares the quarks that needs with the quarks the receiver holds of the mint. The comparison is strict: Session.hasSufficientFunds lets a shortfall under half a denomination through because its callers send its amountToSend, and a return pays the full amount, so it's used only to name the shortfall for the modal. If no fresh rate is available, or no balance of the mint is loaded, the tap shows "Rate Unavailable" / "Couldn't get a fresh rate. Please try again." and opens nothing. If it's short, showYoureShortError(amount:) (or showInsufficientBalanceError() when the shortfall isn't known) shows instead of the sheet, unchanged, with Add More Cash opening deposit for that mint. A tap while another is still pricing does nothing, so Send Back: Tapped fires once and the sheet opens once. The sheet's token name and icon come from the mint's metadata. The same check runs again on swipe; if it can't be made there, the sheet closes and Rate Unavailable shows. If it fails there, the sheet closes, the row goes back to Available and the modal shows. No intent is submitted.

SendBackSheet. Built from StartChattingSheet's parts: PartialSheet, the amount, a read-only CurrencyLabel pill with no chevron, "Send back to ", and SwipeControl reading "Swipe to Send Back".

Submit. The submit path calls Session.returnPayment(amount:verifiedState:to:chatID:messageID:intentID:) to the original sender's owner, resolved through RecipientResolving. On failure it asks for the status once: RETURNED becomes Sent Back with no error; anything else returns the row to Available and shows "Couldn't Send Back" / "Something went wrong. Please try again." If the swipe's own status check already says RETURNED, it goes to Sent Back with no payment. submitDirect reported every chat payment failure as "Requested payment failed"; a return now reports "Send back failed". That string only reaches Bugsnag; userFacing sets the report level and doesn't show a dialog.

The intent ID is never logged, persisted, sent in analytics or put in an error report. The submit log line goes through one helper that prints server-issued in its place, and a test asserts that.

Return bubble captions. A message with return_of reads "You sent back / to " to its sender and " sent back / to You" to its receiver, in the cash-bubbles line order, with no row or footer. A return that itself carries an offer follows the offered-bubble rules.

Cell. ChatCashCardCell gets SendBackRowView attached to the card's bottom edge: a white row with "Send Back" when Available, a spinner when Sending, and a caption-style "Sent Back" footer at the secondary text color. The Available and Sending row is 44 tall, below the card's 16 bottom padding. The Sent Back footer is 36 tall and takes the place of that padding, with its text centred. The card above keeps the #1221 spacing (16 / 12 / 6). The row has its own tap; tapping the card still opens currency info.

No animation when the row or footer appears. ChatViewController.update(items:) applies every push on a spring. When a push changes a cash card's Send Back state, sendBackStage(from:to:) swaps in only those cards, and they're applied first as an in-place reconfigure inside UIView.performWithoutAnimation. The rest of the push, such as the return message arriving with the sender's footer, then animates as before. The transcript can shift by the row's height once, with no slide. Cell height still comes from Auto Layout, and a reused cell drops the row.

Activity. A row with returnedPayment is titled "Sent back to " (sent) or " sent back" (received), falling back to the server's title until the counterpart resolves. Tapping it opens the DM, fetching the chat from the server through hydratedConversation when it isn't stored; when the server doesn't return it, or the fetch fails, the row opens the details screen, as on Android.

Analytics (last commit). Send Back: Tapped fires, with Has Balance, on each tap that reaches the sheet or the insufficient-balance modal. A tap whose balance can't be determined (no rate, or no loaded balance of the mint) doesn't count. Sent Cash fires once per swipe with Context = Send Back: Success; Failure with Already Returned when the status says RETURNED, whether before or after a payment attempt; otherwise Failure with the error name, as other Sent Cash failures send it. No Recipient Count, and no intent, chat or message ID in any property.

Tests

Spec cases 1–19 are in FlipcashTests/Chat/SendBackTests.swift (row, status gating, balance gate, submit outcomes, intent fields, analytics, intent ID kept out of logs) and FlipcashTests/ActivityRowReturnTests.swift (16, 17). Store round trips are in Database+ConversationsTests and Database+ReturnedPaymentTests; row sizes (44, and 36 replacing the padding) and reuse are in ChatCashCardCellTests; the no-animation staging is in ChatSendBackStageTests.

Design comparison

The App column comes from messages mapped through ChatItem.from and drawn by the real ChatCashCardCell and SendBackSheet on an iOS simulator. The frames now use the cash-bubbles DM layout, so line order matches.

Frame App Gap
S1 available (13296:477) "You received / $5.00 / Dollars / from Fred Wilson", then a white row on the card's bottom edge, "Send Back" in appTextSmall (Bold), dark text None.
S2 sending (13296:517) Same lines; white row with a spinner, not tappable Design shows a thin arc. The app's in-button loader (LoadingView, used by CodeButton) is UIActivityIndicatorView, so the row uses that.
S3 confirm sheet (13296:557) Amount, pill, "Send back to Fred Wilson", "Swipe to Send Back" Design's pill is filled. StartChattingSheet uses a stroked capsule (Metrics.inputFieldStrokeColor), which this reuses.
S5 receiver after return (13296:693) Original reads "You received / … / from Fred Wilson / Sent Back"; return reads "You sent back / … / to Fred Wilson" None. The footer has a one-pixel divider along its top edge, as the frame draws.
S6 sender after return (13296:741) Original reads "You sent / … / to Alex Morgan / Sent Back"; return reads "Alex Morgan sent back / … / to You" None.

Token differences, using the app's tokens:

  • Row fill and text: Color.textMain fill, Color.backgroundMain text, in place of the frame's hard-coded white and near-black.
  • "Send Back" weight: the frame asks for SemiBold 14. No SemiBold text token exists, so the row uses appTextSmall (14 Bold).
  • Divider: the Sent Back footer's top edge is a one-pixel line in Color.rowSeparator (white at 10%), the color Row draws its separator in. The frame's line above the Available row is left out: over the white fill it doesn't show.
  • "Sent Back" footer: the card's caption font (12 Bold) and ChatCashCardCell.secondaryColor, which is the 50% white the card's caption and counterpart lines already use.
  • Sheet recipient line: .appTextCaption and Color.textSecondary.
  • Sheet stack spacing: 25 / 12, as StartChattingSheet uses, not the frame's values.

…eturned payment

ConversationMessage carries CashReturn from CashContent.payment_return, stored in two new
message columns, so a reopened DM knows a payment was sent back without asking the server.
Activity carries returnedPayment in a side table beside requestedPayment. Schema 64 -> 65.
The receiver of a cash message carrying a return offer gets a Send Back
row under the bubble. A tap checks the balance at today's rate and opens
a confirm sheet (amount, read-only currency, "Send back to <Name>",
"Swipe to Send Back"), or the existing insufficient-balance modal. The
swipe pays the original's amount, currency and mint back to the
sender's owner under the server-issued intent id.

The status is asked once per DM open, by the receiver only, and only
when no message in the transcript returns the offer. The intent id is
held in memory for the open and never logged; a failed return reports
as "Send back failed" and shows "Couldn't Send Back". Both people see
"Sent Back" on the original once the return is in the transcript, and
the return reads "You sent back" / "<Name> sent back".
A payment that returned a DM's opening payment reads "Sent back to
<Name>" to the one who returned it and "<Name> sent back" to the one
it returned to, falling back to the server's title until the
counterparty resolves. Tapping it opens the DM while it exists, else
the details screen as other rows do.
…drop the row divider

The footer kept its own copy of the card's 50% white, and the Available row drew
an 8% white divider over a white fill, where it can't be seen. Neither has an app
token. The footer now reads ChatCashCardCell.secondaryColor, and the divider is
gone.
The balance check went through Session.hasSufficientFunds, which
answers sufficient for a shortfall under half a denomination because
its callers then send its amountToSend. Send Back pays the full priced
amount, so a balance a fraction of a cent short passed the check and
failed on chain. Compare held quarks with needed quarks instead, on tap
and on swipe; the session check now only names the shortfall for the
modal.

A second tap while the first was pricing reported Send Back: Tapped
twice and could open the sheet twice. Taps now wait for the one in
flight.

The sheet took the token's name from the viewer's balance, falling back
to "Cash" with no icon when there was none. It now takes both from the
mint's metadata.
Tapping a sent-back row looked the DM up in local storage only, so a DM
not stored yet opened the details screen. It now goes through
hydratedConversation, which fetches the chat from the server when it
isn't stored, and opens details only when the server doesn't return it.
…er without animating

The Sent Back footer takes the place of the card's bottom padding. A push that changes a card's
Send Back state applies that card first, in place with animation off, so the bubble doesn't
slide; the rest of the push keeps its spring.
With no fresh rate, or no loaded balance of the payment's mint, the tap and the swipe show Rate
Unavailable and Send Back: Tapped doesn't fire, as on Android.
…ack context

Needs a flipcash-shared-core-spm release carrying the catalogue change
(code-android-app feat/analytics-send-back): until the pin moves, this
commit does not compile against the published package.

Sent Cash fires once per swipe; a return the server already counts as
sent reports Failure with Error "Already Returned". No property carries
the intent, chat or message id.
@bmc08gt
bmc08gt force-pushed the feat/chat-send-back branch from e0f29dd to b2dbc8b Compare November 4, 2026 19:16
@bmc08gt
bmc08gt marked this pull request as ready for review November 4, 2026 19:50
@bmc08gt
bmc08gt merged commit 03adb6a into main Nov 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant