diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ef5c7f1d0..052477a09 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "ddd9ebc01e7ce34a5af8e43ab3214fe791697b80", - "version" : "0.19.0" + "revision" : "27b1d124cc7e685cc151645d7b83d966e526f3c1", + "version" : "0.20.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index cc2977646..e502e615f 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -35,7 +35,7 @@ let sharedCore: Package.Dependency = { } return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.19.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.20.0")) }() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 05a64942e..dfd9240a1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -111,6 +111,8 @@ extension ChatItem { /// How a payment-request widget draws; resolved by the caller from the request store so /// the mapper stays pure. Defaults to the loading card. paymentRequest: (ConversationMessage) -> PaymentRequestCardState = { _ in .loading }, + /// The Send Back row under a cash message; nil draws none. See ``SendBackRow/resolve``. + sendBack: (ConversationMessage) -> ChatCashContent.SendBack? = { _ in nil }, /// Where the viewer's unread messages began at open; the divider heads the first message /// after it that someone else sent. unreadBoundary: UnreadBoundary = .none, @@ -292,7 +294,7 @@ extension ChatItem { case .cash(let fiat): let branding = cashBranding(fiat) let isTip = message.cashAction == .tipped - let card = cashCard( + var card = cashCard( message.cashPayments, receiver: message.cashReceiverID, isFromSelf: isFromSelf, @@ -305,12 +307,17 @@ extension ChatItem { selfUserID: selfUserID, nameOf: nameOf ) + // A return is only ever posted into a DM, so it is named for the DM's two people. + if message.cashReturn?.returnOf != nil, !namesAuthors { + card.lines = ChatCashContent.Lines.sentBack(isFromSelf: isFromSelf, counterpartName: counterpartName) + } content = .cash(ChatCashContent( amount: card.amount, token: branding.token, iconURL: branding.iconURL, isTip: isTip, - lines: card.lines + lines: card.lines, + sendBack: sendBack(message) )) case .deleted(let deletion): content = .deleted( diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 8537f7d74..88247c76e 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -29,6 +29,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onCashCardTap: (String) -> Void /// Fired with a payment request's id bytes when its card is tapped. The owner opens Request details. var onPaymentRequestTap: (Data) -> Void = { _ in } + /// Fired with a cash message's id when its receiver taps Send Back. + var onSendBack: (String) -> Void = { _ in } /// Fired when the user taps a URL in a message. The owner routes it through the deep-link handler, /// falling back to the system browser. let onOpenURL: (URL) -> Void @@ -183,6 +185,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap screen.onPaymentRequestTap = onPaymentRequestTap + screen.onSendBack = onSendBack screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile @@ -248,6 +251,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap screen.onPaymentRequestTap = onPaymentRequestTap + screen.onSendBack = onSendBack screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 2aa4d925f..7f0437f2c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -63,6 +63,8 @@ final class ConversationLoadCoordinator { @ObservationIgnored private var expiryTask: Task? /// Where request widgets read their cards; nil draws them loading. @ObservationIgnored private let paymentRequests: PaymentRequests? + /// Send Back for this open: the status check, the row state and the submit. Nil draws no row. + @ObservationIgnored let sendBack: SendBackController? /// When this transcript opened; paid-share messages after it refetch their request once. @ObservationIgnored private let openedAt: Date = .now @ObservationIgnored private var refetchedPayments: Set = [] @@ -87,9 +89,11 @@ final class ConversationLoadCoordinator { session: Session, knownAuthors: KnownAuthorDirectory, paymentRequests: PaymentRequests? = nil, + sendBack: SendBackController? = nil, openingLoadLanded: Bool = false ) { self.paymentRequests = paymentRequests + self.sendBack = sendBack self.conversationID = conversationID self.controller = controller self.session = session @@ -329,6 +333,19 @@ final class ConversationLoadCoordinator { } } } + // Read inside the observation scope, so a status answer or a submit re-maps the row. + var sendBackRows: [UInt64: ChatCashContent.SendBack] = [:] + if let sendBack { + let returned = SendBackRow.returnedIDs(in: window) + for message in window { + guard case .cash = message.content else { continue } + if SendBackRow.needsStatus(message, selfUserID: controller.selfUserID, returnedIDs: returned) { + sendBack.checkStatusIfNeeded(message.id) + } + let row = SendBackRow.resolve(message, selfUserID: controller.selfUserID, returnedIDs: returned, local: sendBack.local(for: message.id)) + if let row { sendBackRows[message.id.value] = row } + } + } return Inputs( messages: window, selfUserID: controller.selfUserID, @@ -358,6 +375,7 @@ final class ConversationLoadCoordinator { headsHistory: headsHistory, unreadBoundary: unreadBoundary(in: window), paymentRequestCards: paymentRequestCards, + sendBackRows: sendBackRows, // Read inside the observation scope, so a pin or unpin re-maps the menus. pinnedIDs: controller.pins.pinnedIDs(in: conversationID) ) @@ -426,6 +444,7 @@ final class ConversationLoadCoordinator { // here touches the network, and an answer landing cannot re-diff this window. linkCard: { links in classifier.firstCard(in: links) }, paymentRequest: { inputs.paymentRequestCards[$0.id.value] ?? .loading }, + sendBack: { inputs.sendBackRows[$0.id.value] }, unreadBoundary: inputs.unreadBoundary, headsHistory: inputs.headsHistory ) @@ -543,6 +562,8 @@ final class ConversationLoadCoordinator { var unreadBoundary: UnreadBoundary /// Each request widget's card in the window, keyed by raw message id. var paymentRequestCards: [UInt64: PaymentRequestCardState] = [:] + /// Each cash message's Send Back row, keyed by raw message id; absent draws none. + var sendBackRows: [UInt64: ChatCashContent.SendBack] = [:] /// The chat's pinned messages, which decide Pin or Unpin on each row's menu. var pinnedIDs: Set = [] diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 92464d757..3d053cad3 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -101,6 +101,8 @@ struct ConversationScreen: View { @State private var reactionPickerRequest: ReactionPickerRequest? /// The paid reaction whose sheet is open, once the "$" picker has chosen its emoji. @State private var paidReactionTarget: ReactionBoostTarget? + /// The Send Back offer whose confirm sheet is open. + @State private var sendBackOffer: SendBackOffer? /// The group send whose card was tapped, shown in the details sheet. @State private var groupSendDetails: GroupSendDetails? /// The payment request whose details sheet is open. @@ -486,6 +488,7 @@ struct ConversationScreen: View { onPaymentRequestTap: { data in afterKeyboardLowered { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: data)) } }, + onSendBack: { id in afterKeyboardLowered { tapSendBack(id) } }, onOpenURL: openLink, onMentionTap: openMention, onShareProfile: shareProfile, @@ -738,6 +741,11 @@ struct ConversationScreen: View { .sheet(item: $paidReactionTarget) { target in PaidReactionSheet(target: target, selfReactions: selfReactions(on: target.messageID)) } + .sheet(item: $sendBackOffer) { offer in + SendBackSheet(offer: offer) { offer in + _ = await coordinator?.sendBack?.confirm(offer) + } + } .sheet(item: $reactorsRequest, onDismiss: { guard let userID = pendingReactorProfile else { return } pendingReactorProfile = nil @@ -1844,6 +1852,45 @@ struct ConversationScreen: View { } } + /// Send Back for one open of `id`. Not gated on chat type: the row follows the message's offer. + private func makeSendBack(_ id: ConversationID) -> SendBackController? { + let router = router + return SendBackController( + conversationID: id, + services: LiveSendBackServices(session: session, ratesController: ratesController), + analytics: LiveSendBackAnalytics(), + presenter: LiveSendBackPresenter(session: session, ratesController: ratesController) { mint in + router.push(.currencyInfoForDeposit(mint)) + } + ) + } + + /// The receiver's tap on Send Back: the balance check, then the confirm sheet or the + /// insufficient-balance modal. The id is the row's stable id; resolve it to the cash message. + private func tapSendBack(_ messageID: String) { + guard let conversationID, + let sendBack = coordinator?.sendBack, + let message = (coordinator?.loader.messages ?? []).first(where: { $0.stableID == messageID }), + case .cash(let fiat) = message.content, + let senderID = message.senderID + else { return } + let balance = session.balance(for: fiat.mint) + let offer = SendBackOffer( + conversationID: conversationID, + messageID: message.id, + senderID: senderID, + original: fiat, + recipientName: title, + token: balance?.name ?? "Cash", + iconURL: balance?.imageURL + ) + Task { + if let confirmed = await sendBack.tap(offer) { + sendBackOffer = confirmed + } + } + } + /// The gate panel's CTA: buys the mint the requirement names, or opens add-cash when there is no /// one token to buy — a requirement spanning every holding, and a dollar-token one, which is /// satisfied by adding cash rather than by swapping into the thing the cash already is. Both @@ -2120,6 +2167,7 @@ struct ConversationScreen: View { session: session, knownAuthors: sessionContainer.knownAuthors, paymentRequests: sessionContainer.paymentRequests, + sendBack: makeSendBack(id), openingLoadLanded: loadedConversationID == id ) } diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift new file mode 100644 index 000000000..2e1a3bc40 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift @@ -0,0 +1,283 @@ +// +// SendBackController.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// What Send Back needs from the session, behind a protocol so the controller is testable. +@MainActor +protocol SendBackServicing: AnyObject { + /// `GetCashMessageReturnStatus` for `messageID` in `conversationID`, as the viewer. + func cashMessageReturnStatus(conversationID: ConversationID, messageID: MessageID) async throws -> CashReturnStatus + /// `original` re-priced in its own currency and mint at today's rate, with the proof it was + /// priced at; nil when no fresh rate is available. + func priceReturn(of original: ExchangedFiat) async -> (amount: ExchangedFiat, pin: VerifiedState)? + /// The quarks of `mint` the viewer holds; nil when no balance of `mint` is loaded. + func heldQuarks(of mint: PublicKey) -> UInt64? + /// The shortfall to show in the insufficient-balance modal, when the session can name it. + func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult + /// The token's name and icon from its mint metadata; nil when the metadata can't be loaded. + func tokenLabel(for mint: PublicKey) async -> (name: String, iconURL: URL?)? + /// The owner account of `userID`, where the return is paid. + func resolveUserID(_ userID: UserID) async throws -> PublicKey + func returnPayment(amount: ExchangedFiat, verifiedState: VerifiedState, to senderOwner: PublicKey, chatID: ConversationID, messageID: MessageID, intentID: PublicKey) async throws +} + +/// What Send Back reports to analytics. Never carries the intent, chat or message id. +@MainActor +protocol SendBackAnalytics: AnyObject { + /// `Send Back: Tapped`, after the balance check and before the sheet or modal. + func tapped(hasBalance: Bool) + /// `Sent Cash` with the `Send Back` context, once per swipe. + func sent(_ outcome: SendBackOutcome, amount: ExchangedFiat) +} + +/// How a swiped return ended, as `Sent Cash` reports it. +enum SendBackOutcome { + case success + /// The server says the payment was already sent back, by an earlier tap or another device. + case alreadyReturned + case failed(Error) +} + +/// What Send Back shows that is not a transcript row, so tests can observe it. +@MainActor +protocol SendBackPresenting: AnyObject { + /// The existing insufficient-balance modal: "You're $X Short" when the shortfall is known, else + /// "You Need More Cash". Its Add More Cash opens deposit for `mint`. + func showInsufficientBalance(shortfall: ExchangedFiat?, mint: PublicKey) + /// "Couldn't Send Back" / "Something went wrong. Please try again." + func showSendBackError() + /// No fresh rate to price the return; the app's standard "Rate Unavailable". + func showRateUnavailable() +} + +/// The offer the confirm sheet shows and the swipe returns. +struct SendBackOffer: Identifiable, Equatable { + let conversationID: ConversationID + let messageID: MessageID + /// The original sender, who is paid the return. + let senderID: UserID + /// The original's total, in its currency and mint. + let original: ExchangedFiat + /// The DM peer's display name, as the chat title shows it. + let recipientName: String + var token: String + var iconURL: URL? + + var id: MessageID { messageID } +} + +/// Send Back for one DM open: the one status check per offered message, the row state, the +/// confirm-sheet gates, and the submit. The intent id lives here, in memory, for this open only. +@MainActor +@Observable +final class SendBackController { + + /// Each offered message's state this open, keyed by message id. Read by the coordinator inside + /// its observation scope, so a change re-maps the row. + private(set) var states: [MessageID: SendBackRow.Local] = [:] + + /// The server's intent id per message. Never logged, persisted, reported, or put in analytics. + @ObservationIgnored private var intentIDs: [MessageID: PublicKey] = [:] + @ObservationIgnored private var asked: Set = [] + + @ObservationIgnored let conversationID: ConversationID + @ObservationIgnored private let services: any SendBackServicing + @ObservationIgnored private let analytics: any SendBackAnalytics + @ObservationIgnored private let presenter: any SendBackPresenting + + /// The submit of the last swipe, kept so a test can wait for it. + @ObservationIgnored private(set) var submitTask: Task? + /// The last status check started, kept so a test can wait for it. + @ObservationIgnored private(set) var statusTask: Task? + + init(conversationID: ConversationID, services: any SendBackServicing, analytics: any SendBackAnalytics, presenter: any SendBackPresenting) { + self.conversationID = conversationID + self.services = services + self.analytics = analytics + self.presenter = presenter + } + + func local(for messageID: MessageID) -> SendBackRow.Local { + states[messageID] ?? .unknown + } + + // MARK: - Status - + + /// Asks the server about `messageID` once this open. A failed check leaves the row hidden and + /// is not retried until the DM opens again; no error is shown. + func checkStatusIfNeeded(_ messageID: MessageID) { + guard asked.insert(messageID).inserted else { return } + statusTask = Task { await self.fetchStatus(messageID, updatingRow: true) } + } + + /// The status, or nil when the call failed. With `updatingRow`, an answer moves a row that is + /// not mid-submit. + @discardableResult + private func fetchStatus(_ messageID: MessageID, updatingRow: Bool) async -> CashReturnStatus.State? { + let status: CashReturnStatus + do { + status = try await services.cashMessageReturnStatus(conversationID: conversationID, messageID: messageID) + } catch { + return nil + } + switch status.state { + case .available: + if let data = status.paymentIntentID, let key = try? PublicKey(data) { + intentIDs[messageID] = key + } + if updatingRow, local(for: messageID) == .unknown { + states[messageID] = .available + } + case .returned: + intentIDs[messageID] = nil + if updatingRow { states[messageID] = .sentBack } + case .unknown: + break + } + return status.state + } + + // MARK: - Tap - + + /// The tap on Send Back. Returns the offer to confirm, or nil when the balance is short (the + /// modal is up instead) or the return could not be priced. + func tap(_ offer: SendBackOffer) async -> SendBackOffer? { + // One tap at a time: a double tap would report Send Back: Tapped twice and open the sheet twice. + guard !tapInFlight, local(for: offer.messageID) == .available else { return nil } + tapInFlight = true + defer { tapInFlight = false } + guard let priced = await services.priceReturn(of: offer.original) else { + presenter.showRateUnavailable() + return nil + } + // A tap whose balance can't be determined reaches neither the sheet nor the modal, so the + // funnel doesn't count it. + guard let hasBalance = checkBalance(priced.amount) else { + presenter.showRateUnavailable() + return nil + } + analytics.tapped(hasBalance: hasBalance) + guard hasBalance else { return nil } + var confirmed = offer + if let token = await services.tokenLabel(for: offer.original.mint) { + confirmed.token = token.name + confirmed.iconURL = token.iconURL + } + return confirmed + } + + private var tapInFlight = false + + // MARK: - Swipe - + + enum ConfirmOutcome: Equatable { + /// The return is submitting; the row shows Sending. + case submitted + /// Not submitted: the balance fell short (the modal is up) or no rate. The row stays Available. + case blocked + } + + /// The swipe. Checks the balance again, since it can change while the sheet is open, then + /// starts the submit. The sheet dismisses either way. + func confirm(_ offer: SendBackOffer) async -> ConfirmOutcome { + guard local(for: offer.messageID) == .available else { return .blocked } + guard let priced = await services.priceReturn(of: offer.original) else { + presenter.showRateUnavailable() + return .blocked + } + switch checkBalance(priced.amount) { + case true?: break + case false?: return .blocked + case nil: + presenter.showRateUnavailable() + return .blocked + } + states[offer.messageID] = .sending + submitTask = Task { await self.submit(offer, amount: priced.amount, pin: priced.pin) } + return .submitted + } + + /// Whether the held quarks cover `amount`'s quarks; when they don't, shows the + /// insufficient-balance modal. Nil, with nothing shown, when no balance of the mint is loaded. + /// + /// Strict on purpose: the return pays exactly `amount`. `Session.hasSufficientFunds` forgives a + /// shortfall under half a denomination because its callers send its `amountToSend` instead; + /// this one can't, so a balance a fraction of a cent short would fail on chain. + private func checkBalance(_ amount: ExchangedFiat) -> Bool? { + guard let held = services.heldQuarks(of: amount.mint) else { return nil } + let needed = amount.onChainAmount.quarks + if needed > 0, held >= needed { + return true + } + var shortfall: ExchangedFiat? + if case .insufficient(let known) = services.hasSufficientFunds(for: amount) { + shortfall = known + } + presenter.showInsufficientBalance(shortfall: shortfall, mint: amount.mint) + return false + } + + private func submit(_ offer: SendBackOffer, amount: ExchangedFiat, pin: VerifiedState) async { + let messageID = offer.messageID + + // 1. The intent id from this open's check, else ask now. + if intentIDs[messageID] == nil { + switch await fetchStatus(messageID, updatingRow: false) { + case .returned: + states[messageID] = .sentBack + analytics.sent(.alreadyReturned, amount: amount) + return + default: + break + } + } + guard let intentID = intentIDs[messageID] else { + fail(messageID, amount: amount, error: SendBackError.intentUnavailable) + return + } + + // 2. Pay the original sender's owner under the server's intent id. + do { + let owner = try await services.resolveUserID(offer.senderID) + try await services.returnPayment( + amount: amount, + verifiedState: pin, + to: owner, + chatID: offer.conversationID, + messageID: messageID, + intentID: intentID + ) + } catch { + // 4. A failure that the server already counts as returned is a success to the viewer. + if await fetchStatus(messageID, updatingRow: false) == .returned { + states[messageID] = .sentBack + analytics.sent(.alreadyReturned, amount: amount) + } else { + fail(messageID, amount: amount, error: error) + } + return + } + + // 3. The return message arrives through the stream; the row moves now. + intentIDs[messageID] = nil + states[messageID] = .sentBack + analytics.sent(.success, amount: amount) + } + + private func fail(_ messageID: MessageID, amount: ExchangedFiat, error: Error) { + states[messageID] = .available + analytics.sent(.failed(error), amount: amount) + presenter.showSendBackError() + } +} + +enum SendBackError: Error { + /// The status check gave no intent id to submit the return under. + case intentUnavailable +} diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackRow.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackRow.swift new file mode 100644 index 000000000..de209d121 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackRow.swift @@ -0,0 +1,67 @@ +// +// SendBackRow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Which Send Back row a cash message draws, from the transcript and what this DM open has learned +/// about the offer. Pure, so the mapper and the tests share it. +enum SendBackRow { + + /// What this open knows about an offered message's return, as ``SendBackController`` tracks it. + enum Local: Equatable, Sendable { + /// No status yet: never asked, in flight, or the check failed. The row stays hidden. + case unknown + /// The server says the payment can still be sent back. + case available + /// The receiver swiped and the return has not resolved. + case sending + /// The return landed, or the server says it already had. + case sentBack + } + + /// The row for `message`: Sent Back on either side once a message in the transcript returns it, + /// else the receiver's view of an open offer. The sender of an offer never sees Send Back. + nonisolated static func resolve( + _ message: ConversationMessage, + selfUserID: UserID, + returnedIDs: Set, + local: Local + ) -> ChatCashContent.SendBack? { + guard case .cash = message.content else { return nil } + if returnedIDs.contains(message.id) { return .sentBack } + guard isOffered(message, to: selfUserID) else { return nil } + switch local { + case .unknown: return nil + case .available: return .available + case .sending: return .sending + case .sentBack: return .sentBack + } + } + + /// Whether `message` offers `viewer` a return: a cash message carrying an offer whose single + /// receiver is the viewer. Only that viewer sees Send Back or asks the server about it. + nonisolated static func isOffered(_ message: ConversationMessage, to viewer: UserID) -> Bool { + guard case .cash = message.content, + message.cashReturn?.hasOffer == true, + message.cashReceiverID == viewer, + !message.isFromSelf(viewer) else { return false } + return true + } + + /// The messages a message in `window` returns. A return is always later in the DM than the + /// message it returns, so a loaded original always has its return loaded too. + nonisolated static func returnedIDs(in window: [ConversationMessage]) -> Set { + Set(window.compactMap { $0.cashReturn?.returnOf?.messageID }) + } + + /// Whether this open should ask the server about `message`: it is offered to the viewer and + /// nothing in the transcript returns it yet. + nonisolated static func needsStatus(_ message: ConversationMessage, selfUserID: UserID, returnedIDs: Set) -> Bool { + isOffered(message, to: selfUserID) && !returnedIDs.contains(message.id) + } +} diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift new file mode 100644 index 000000000..4c357f78f --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift @@ -0,0 +1,127 @@ +// +// SendBackServices.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashUI + +/// Send Back's session, rates and payment, as the app wires them. +@MainActor +final class LiveSendBackServices: SendBackServicing { + + private let session: Session + private let ratesController: RatesController + + init(session: Session, ratesController: RatesController) { + self.session = session + self.ratesController = ratesController + } + + func cashMessageReturnStatus(conversationID: ConversationID, messageID: MessageID) async throws -> CashReturnStatus { + try await session.cashMessageReturnStatus(conversationID: conversationID, messageID: messageID) + } + + /// The same fiat amount, currency and mint as `original`, at today's rate. + func priceReturn(of original: ExchangedFiat) async -> (amount: ExchangedFiat, pin: VerifiedState)? { + guard let pin = await ratesController.currentPinnedState(for: original.nativeAmount.currency, mint: original.mint), + let amount = session.price(original.nativeAmount, mint: original.mint, at: pin) else { + return nil + } + return (amount, pin) + } + + func heldQuarks(of mint: PublicKey) -> UInt64? { + session.balance(for: mint)?.quarks + } + + func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult { + session.hasSufficientFunds(for: amount) + } + + func tokenLabel(for mint: PublicKey) async -> (name: String, iconURL: URL?)? { + guard let metadata = try? await session.fetchMintMetadata(mint: mint) else { return nil } + return (metadata.name, metadata.imageURL) + } + + func resolveUserID(_ userID: UserID) async throws -> PublicKey { + try await session.resolveUserID(userID) + } + + func returnPayment(amount: ExchangedFiat, verifiedState: VerifiedState, to senderOwner: PublicKey, chatID: ConversationID, messageID: MessageID, intentID: PublicKey) async throws { + try await session.returnPayment(amount: amount, verifiedState: verifiedState, to: senderOwner, chatID: chatID, messageID: messageID, intentID: intentID) + } +} + +/// Send Back's modals, on the session's dialog window, so they show over a sheet that is closing. +@MainActor +final class LiveSendBackPresenter: SendBackPresenting { + + private let session: Session + private let ratesController: RatesController + private let openDeposit: (PublicKey) -> Void + + init(session: Session, ratesController: RatesController, openDeposit: @escaping (PublicKey) -> Void) { + self.session = session + self.ratesController = ratesController + self.openDeposit = openDeposit + } + + /// The modal `ReactionBoostViewModel` shows, unchanged. + func showInsufficientBalance(shortfall: ExchangedFiat?, mint: PublicKey) { + let openDeposit = openDeposit + if let shortfall { + session.dialogItem = .error( + title: "You're \(ratesController.displayCurrency.amount(shortfall).formatted()) Short", + subtitle: "Add more cash, or try again with a lower amount" + ) { + .destructive("Add More Cash") { openDeposit(mint) }; + .dismiss(kind: .subtle) + } + } else { + session.dialogItem = .error( + title: "You Need More Cash", + subtitle: "Please add more cash, or try again with a lower amount" + ) { + .destructive("Add More Cash") { openDeposit(mint) }; + .dismiss(kind: .subtle) + } + } + } + + func showSendBackError() { + session.dialogItem = .error( + title: "Couldn't Send Back", + subtitle: "Something went wrong. Please try again." + ) + } + + func showRateUnavailable() { + session.dialogItem = .error( + title: "Rate Unavailable", + subtitle: "Couldn't get a fresh rate. Please try again." + ) + } +} + +/// Send Back's analytics: `Send Back: Tapped`, and `Sent Cash` with the `Send Back` context. +@MainActor +final class LiveSendBackAnalytics: SendBackAnalytics { + func tapped(hasBalance: Bool) { + Analytics.sendBackTapped(hasBalance: hasBalance) + } + + func sent(_ outcome: SendBackOutcome, amount: ExchangedFiat) { + switch outcome { + case .success: + Analytics.sentCashBack(exchangedFiat: amount, error: nil) + case .alreadyReturned: + Analytics.sentCashBack(exchangedFiat: amount, error: nil, alreadyReturned: true) + case .failed(let error): + Analytics.sentCashBack(exchangedFiat: amount, error: error) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackSheet.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackSheet.swift new file mode 100644 index 000000000..5870ee9e7 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackSheet.swift @@ -0,0 +1,74 @@ +// +// SendBackSheet.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// Resets the swipe knob without its checkmark: the sheet closes on the swipe and the row, not the +/// sheet, shows how the return goes. +private struct SendBackSheetDismissed: Error {} + +/// Confirms sending a DM's opening payment back: the amount, the currency it goes back in, who gets +/// it, and a swipe. Nothing here can be changed — the return is the original's amount, in its +/// currency, to its sender — so the currency is a label, not a picker (design node 13296:557). +struct SendBackSheet: View { + + @Environment(\.dismiss) private var dismiss + + let offer: SendBackOffer + /// The swipe. Returns once the return is submitting or was blocked; the sheet closes either way. + let onSwipe: (SendBackOffer) async -> Void + + var body: some View { + PartialSheet(background: .clear) { + VStack(spacing: 25) { + VStack(spacing: 12) { + Text(offer.original.nativeAmount.formatted()) + .font(.appDisplayLarge) + .foregroundStyle(Color.textMain) + .lineLimit(1) + .minimumScaleFactor(0.4) + + CurrencyLabel(imageURL: offer.iconURL, name: offer.token, amount: nil) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background { + Capsule() + .strokeBorder( + Metrics.inputFieldStrokeColor(highlighted: false), + lineWidth: Metrics.inputFieldBorderWidth(highlighted: false) + ) + } + + Text("Send back to \(offer.recipientName)") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + + SwipeControl(text: "Swipe to Send Back") { + await onSwipe(offer) + dismiss() + throw SendBackSheetDismissed() + } + } + .padding(.horizontal, 20) + .padding(.top, 30) + .padding(.bottom, bottomPadding) + } + } + + /// The design's 20 under the swipe, less whatever the sheet already holds back for the home + /// indicator — stacking the two doubles the gap. As ``StartChattingSheet``. + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 20 - reserved) + } +} diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index f3bebdca1..902a9539a 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -31,7 +31,17 @@ struct ActivityRow: View { var body: some View { Button { - router.push(.transactionDetails(activity)) + guard activity.returnedPayment != nil else { + router.push(.transactionDetails(activity)) + return + } + let conversations = sessionContainer.conversationController + Task { + let destination = await Self.destination(for: activity) { + await conversations.hydratedConversation(withID: $0) != nil + } + router.push(destination) + } } label: { content } @@ -111,7 +121,8 @@ struct ActivityRow: View { boostedReaction: activity.boostedReaction, name: resolution.counterpartyName, chatName: boostChatName, - serverTitle: activity.title + serverTitle: activity.title, + isReturn: activity.returnedPayment != nil ) ?? activity.title } @@ -135,14 +146,43 @@ struct ActivityRow: View { boostedReaction: Activity.BoostedReaction?, name: String?, chatName: String?, - serverTitle: String + serverTitle: String, + isReturn: Bool = false ) -> String? { + if isReturn { + return returnTitle(kind: kind, name: name) + } guard let boostedReaction else { return peerTitle(kind: kind, name: name, serverTitle: serverTitle) } return boostTitle(kind: kind, emoji: boostedReaction.emoji, name: name, chatName: chatName) } + /// Where a tap on the row goes: a payment that sent back a DM's opening payment opens that DM + /// when `dmExists` finds it, stored or fetched from the server; every other row, and one whose + /// DM is gone or can't be fetched, opens its details. + static func destination( + for activity: Activity, + dmExists: (ConversationID) async -> Bool + ) async -> AppRouter.Destination { + if let returned = activity.returnedPayment, await dmExists(returned.conversationID) { + return .tipConversation(returned.conversationID) + } + return .transactionDetails(activity) + } + + /// How a payment that sent back a DM's opening payment is titled: "Sent back to " by the + /// one who returned it, " sent back" to the one it returned to. `nil` until the + /// counterparty resolves, so the caller falls back to the server's title. + static func returnTitle(kind: Activity.Kind, name: String?) -> String? { + guard let name, !name.isEmpty else { return nil } + switch kind { + case .gave: return "Sent back to \(name)" + case .received: return "\(name) sent back" + default: return nil + } + } + /// How a boosted payment is titled: "Boosted 🔥 for Sally" / "Boost 🔥 from Sally" once the /// counterparty resolves, "Boosted 🔥 in Design Team" for a split boost whose chat is known, /// and "Boosted 🔥" / "Boost 🔥 received" when there is no name to use. Returns `nil` for a diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index b8a0bd7be..0f89464a9 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -1133,6 +1133,12 @@ class Session { ) } + /// Whether cash message `messageID` in `conversationID` can still be sent back, and the intent id + /// to send it under. The intent id is never logged or reported. + func cashMessageReturnStatus(conversationID: ConversationID, messageID: MessageID) async throws -> CashReturnStatus { + try await flipClient.getCashMessageReturnStatus(owner: ownerKeyPair, conversationID: conversationID, messageID: messageID) + } + private func submitDirect(amount: ExchangedFiat, verifiedState: VerifiedState, to recipientOwner: PublicKey, chat: ChatPaymentMetadata?, intentID: PublicKey?) async throws { try assertFresh(verifiedState, operation: "send", currency: amount.nativeAmount.currency, mint: amount.mint) @@ -1155,7 +1161,7 @@ class Session { "mint": "\(mint.base58)", "recipientOwner": "\(recipientOwner.base58)", "recipientVault": "\(recipientVault.base58)", - "rendezvous": "\(tracesRendezvous ? rendezvous.base58 : "server-issued")", + "rendezvous": "\(Self.rendezvousLogValue(rendezvous, serverIssued: !tracesRendezvous))", "hasChatMetadata": "\(chat != nil)", ]) @@ -1176,7 +1182,7 @@ class Session { guard !(error is CancellationError) else { throw error } logger.error("Send failed", metadata: [ "recipientOwner": "\(recipientOwner.base58)", - "rendezvous": "\(tracesRendezvous ? rendezvous.base58 : "server-issued")", + "rendezvous": "\(Self.rendezvousLogValue(rendezvous, serverIssued: !tracesRendezvous))", "amount": "\(amount.nativeAmount.formatted())", "error": "\(error)", ]) @@ -1188,12 +1194,25 @@ class Session { userFacing: true ) } else { - ErrorReporting.captureError(error, reason: "Requested payment failed", userFacing: true) + ErrorReporting.captureError(error, reason: Self.serverIssuedFailureReason(chat), userFacing: true) } throw error } } + /// What the payment logs show for its rendezvous. A server-issued intent id is private to the + /// payment, so it is never written out. + nonisolated static func rendezvousLogValue(_ rendezvous: PublicKey, serverIssued: Bool) -> String { + serverIssued ? "server-issued" : rendezvous.base58 + } + + /// What a failed payment under a server-issued intent id is reported as: a return of a DM's + /// opening payment, or a payment request share. + nonisolated static func serverIssuedFailureReason(_ chat: ChatPaymentMetadata?) -> String { + if case .returnPayment = chat { return "Send back failed" } + return "Requested payment failed" + } + /// `fiat` (in `pin.rate.currency`) priced in `mint` at `pin`, or nil when it can't be priced. /// The one pricing step every part of a multi-recipient payment goes through. func price(_ fiat: FiatAmount, mint: PublicKey, at pin: VerifiedState) -> ExchangedFiat? { diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 92b8c58b1..cdae57522 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -302,6 +302,28 @@ extension Analytics { ) } + /// A swiped Send Back ended. `alreadyReturned` is a failure with `Error` = `Already Returned`: + /// the server says the payment was sent back already, so this swipe paid nothing. Carries no + /// intent, chat or message id. + static func sentCashBack(exchangedFiat: ExchangedFiat, error: Error?, alreadyReturned: Bool = false) { + track( + TransferEvents.shared.sentCash( + state: alreadyReturned ? .failure : .from(error), + amount: exchangedFiat.analyticsAmount, + error: alreadyReturned ? "Already Returned" : nil, + context: .sendBack, + recipientCount: nil + ), + error: alreadyReturned ? nil : error + ) + } + + /// The receiver of a DM's opening payment tapped Send Back. `hasBalance` is false when the + /// insufficient-balance modal showed instead of the confirm sheet. + static func sendBackTapped(hasBalance: Bool) { + track(TransferEvents.shared.sendBackTapped(hasBalance: hasBalance)) + } + /// A reaction boost was paid for. `exchangedFiat` is the whole boost, before the split. /// `error` is nil on success. static func sentBoost(chatType: ConversationType?, exchangedFiat: ExchangedFiat, error: Error?) { diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index 18ab8f991..c5a854dc9 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -66,7 +66,7 @@ let sharedCore: Package.Dependency = { } return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.19.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.20.0")) }() let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index 5c37ba122..c8a0ce823 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -35,6 +35,11 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// time: the request may since have closed, or the viewer may no longer be able to read it. public let requestedPayment: RequestedPayment? + /// The DM cash message this payment returned, when it was a return: the viewer sent it back, or + /// the counterparty sent the viewer's payment back. A snapshot from payment time: the message may + /// since have been deleted. + public let returnedPayment: ReturnedPayment? + public var cancellableCashLinkMetadata: CashLinkMetadata? { switch state { case .pending: @@ -55,7 +60,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { return nil } - public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil, boostedReaction: BoostedReaction? = nil, requestedPayment: RequestedPayment? = nil) { + public init(id: PublicKey, state: State, kind: Kind, title: String, exchangedFiat: ExchangedFiat, date: Date, metadata: Metadata?, textSubstitutions: [TextSubstitution] = [], counterparty: Counterparty? = nil, boostedReaction: BoostedReaction? = nil, requestedPayment: RequestedPayment? = nil, returnedPayment: ReturnedPayment? = nil) { self.id = id self.state = state self.kind = kind @@ -67,6 +72,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { self.counterparty = counterparty self.boostedReaction = boostedReaction self.requestedPayment = requestedPayment + self.returnedPayment = returnedPayment } } @@ -228,7 +234,8 @@ extension Activity { textSubstitutions: substitutions, counterparty: .init(proto.additionalMetadata), boostedReaction: .init(proto.additionalMetadata), - requestedPayment: .init(proto.additionalMetadata) + requestedPayment: .init(proto.additionalMetadata), + returnedPayment: .init(proto.additionalMetadata) ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 628637b7a..b8c6f17ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -495,6 +495,19 @@ public struct ChatCashContent: Hashable, Sendable, Codable { /// The caption, total and counterpart lines a transcript card draws around the amount; nil /// where only the plain caption is wanted. public let lines: Lines? + /// The Send Back row attached to the card's bottom edge; nil draws the card without one. + public let sendBack: SendBack? + + /// The row under a DM's opening payment that returns it. The receiver sees every state; the + /// original sender sees only ``sentBack``. + public enum SendBack: String, Hashable, Sendable, Codable { + /// The receiver can tap to send the payment back. + case available + /// The return was swiped and its intent has not resolved. + case sending + /// The payment was sent back. Not tappable. + case sentBack + } /// The lines a transcript cash card draws around its amount. public struct Lines: Hashable, Sendable, Codable { @@ -523,14 +536,24 @@ public struct ChatCashContent: Hashable, Sendable, Codable { self.part = part self.counterpart = counterpart } + + /// A return of a DM's opening payment: "You sent back" / "to Alex" to its sender, "Alex sent + /// back" / "to you" to its receiver. An unknown name reads "Someone". + public static func sentBack(isFromSelf: Bool, counterpartName: String) -> Lines { + let name = counterpartName.isEmpty ? "Someone" : counterpartName + return isFromSelf + ? .init(caption: "You sent back", counterpart: "to \(name)") + : .init(caption: "\(name) sent back", counterpart: "to you") + } } - public init(amount: String, token: String, iconURL: URL? = nil, isTip: Bool = false, lines: Lines? = nil) { + public init(amount: String, token: String, iconURL: URL? = nil, isTip: Bool = false, lines: Lines? = nil, sendBack: SendBack? = nil) { self.amount = amount self.token = token self.iconURL = iconURL self.isTip = isTip self.lines = lines + self.sendBack = sendBack } /// The caption shown above the amount on a cash card, selected by side and tip vs. plain send. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 0afa889f7..d05515629 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -145,8 +145,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { public let content: Content /// How a `.cash` message was delivered (sent vs. tipped); `nil` for non-cash content. public let cashAction: CashAction? - /// The member a group-chat `.cash` payment was sent to; `nil` for DM cash (the counterpart is - /// implicit) and for non-cash content. Persisted, so it survives a relaunch. + /// The member a single-receiver `.cash` payment was sent to (the wire's `single_receiver`), in a + /// DM as in a group; `nil` for a multi-receiver payment and for non-cash content. Persisted, so + /// it survives a relaunch. public let cashReceiverID: UserID? /// The per-receiver parts of a group-chat `.cash` payment sent to several members at once (the /// `.cash` amount is their total); empty for a single-receiver payment and for non-cash content. @@ -155,6 +156,10 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// The payment request whose share this `.cash` message paid; `nil` for any other cash message and /// for non-cash content. Not persisted, so a message read back from the local store has `nil`. public let paymentRequestID: PaymentRequestID? + /// A `.cash` message's part in a return: whether its receiver is offered one, and the message it + /// returns, if any. `nil` for a cash message that is neither and for non-cash content. Persisted, + /// so a reopened DM knows a payment was returned without asking the server. + public let cashReturn: CashReturn? public let date: Date public let unreadSeq: UInt64 /// The event-log version at which this message reached its current state, @@ -204,6 +209,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { cashReceiverID: UserID? = nil, cashPayments: [CashPayment] = [], paymentRequestID: PaymentRequestID? = nil, + cashReturn: CashReturn? = nil, date: Date, unreadSeq: UInt64, eventSequence: UInt64 = 0, @@ -224,6 +230,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.cashReceiverID = cashReceiverID self.cashPayments = cashPayments self.paymentRequestID = paymentRequestID + self.cashReturn = cashReturn self.date = date self.unreadSeq = unreadSeq self.eventSequence = eventSequence @@ -269,6 +276,7 @@ extension ConversationMessage { cashReceiverID: cashReceiverID, cashPayments: cashPayments, paymentRequestID: paymentRequestID, + cashReturn: cashReturn, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -337,6 +345,7 @@ extension ConversationMessage { cashReceiverID: cashReceiverID, cashPayments: cashPayments, paymentRequestID: paymentRequestID, + cashReturn: cashReturn, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -402,6 +411,7 @@ extension ConversationMessage { var cashReceiverID: UserID? var cashPayments: [CashPayment] = [] var paymentRequestID: PaymentRequestID? + var cashReturn: CashReturn? switch proto.content.first?.type { case .text(let textContent): self.content = .text(textContent.text) @@ -430,6 +440,7 @@ extension ConversationMessage { break } paymentRequestID = cashContent.hasPaymentRequestID ? PaymentRequestID(cashContent.paymentRequestID) : nil + cashReturn = CashReturn(cashContent) repliedTo = nil case .deleted(let deletedContent): self.content = .deleted( @@ -497,6 +508,7 @@ extension ConversationMessage { self.cashReceiverID = cashReceiverID self.cashPayments = cashPayments self.paymentRequestID = paymentRequestID + self.cashReturn = cashReturn self.senderID = try? UUID(data: proto.senderID.value) self.date = proto.hasTs ? proto.ts.date : .now self.unreadSeq = proto.unreadSeq diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 8b4e2ce76..b66fbf86b 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -103,7 +103,10 @@ nonisolated extension Database { b.emoji AS boostEmoji, r.conversationID AS requestConversationID, - r.paymentRequestID AS requestPaymentRequestID + r.paymentRequestID AS requestPaymentRequestID, + + rp.conversationID AS returnConversationID, + rp.messageID AS returnMessageID FROM activity a @@ -111,6 +114,7 @@ nonisolated extension Database { LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id LEFT JOIN requestedPayment r ON r.id = a.id + LEFT JOIN returnedPayment rp ON rp.id = a.id WHERE a.mint = ? @@ -159,7 +163,10 @@ nonisolated extension Database { b.emoji AS boostEmoji, r.conversationID AS requestConversationID, - r.paymentRequestID AS requestPaymentRequestID + r.paymentRequestID AS requestPaymentRequestID, + + rp.conversationID AS returnConversationID, + rp.messageID AS returnMessageID FROM activity a @@ -167,6 +174,7 @@ nonisolated extension Database { LEFT JOIN swapMetadata s ON s.id = a.id LEFT JOIN boostedReaction b ON b.id = a.id LEFT JOIN requestedPayment r ON r.id = a.id + LEFT JOIN returnedPayment rp ON rp.id = a.id ORDER BY a.date DESC LIMIT ?; @@ -208,7 +216,24 @@ nonisolated extension Database { metadata: metadata(for: kind, row: row), counterparty: Activity.Counterparty(userID: row[a.counterpartyUserID], phone: row[a.counterpartyPhone]), boostedReaction: boostedReaction(from: row), - requestedPayment: requestedPayment(from: row) + requestedPayment: requestedPayment(from: row), + returnedPayment: returnedPayment(from: row) + ) + } + + /// Reads the joined `returnedPayment` columns, aliased in the SELECT. Both are NULL for a payment + /// that returned nothing. + private func returnedPayment(from row: RowIterator.Element) -> Activity.ReturnedPayment? { + guard + let conversationID = row[Expression("returnConversationID")], + let messageID = row[Expression("returnMessageID")] + else { + return nil + } + + return Activity.ReturnedPayment( + conversationID: ConversationID(data: conversationID), + messageID: MessageID(value: messageID) ) } @@ -376,6 +401,21 @@ nonisolated extension Database { } else { try writer.run(requestTable.table.filter(requestTable.id == activity.id).delete()) } + + let returnTable = ReturnedPaymentTable() + if let payment = activity.returnedPayment { + try writer.run( + returnTable.table.upsert( + returnTable.id <- activity.id, + returnTable.conversationID <- payment.conversationID.data, + returnTable.messageID <- payment.messageID.value, + + onConflictOf: returnTable.id, + ) + ) + } else { + try writer.run(returnTable.table.filter(returnTable.id == activity.id).delete()) + } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 79047422c..26257f61c 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -772,7 +772,9 @@ nonisolated extension Database { m.systemAdmittedBy <- systemAdmittedBy, m.systemInvitedBy <- systemInvitedBy, m.cashPaymentsJson <- Self.encodeCashPayments(message.cashPayments), - m.cashReceiverId <- message.cashReceiverID + m.cashReceiverId <- message.cashReceiverID, + m.cashReturnOffer <- message.cashReturn.map(\.hasOffer), + m.cashReturnOfId <- message.cashReturn?.returnOf?.messageID.value ) ) return true @@ -852,6 +854,14 @@ nonisolated extension Database { data.flatMap { try? JSONDecoder().decode([CashPayment].self, from: $0) } ?? [] } + /// The `CashReturn` a cash row's two columns hold; nil when the row neither offers a return nor + /// is one. + nonisolated static func decodeCashReturn(offer: Bool?, returnOf: UInt64?) -> CashReturn? { + let returnOf = returnOf.map { CashReturn.ReturnOf(messageID: MessageID(value: $0)) } + guard offer == true || returnOf != nil else { return nil } + return CashReturn(hasOffer: offer == true, returnOf: returnOf) + } + nonisolated private static func decodeReactions(_ data: Data?) -> ReactionState? { data.flatMap { try? JSONDecoder().decode(ReactionState.self, from: $0) } } @@ -1039,6 +1049,7 @@ nonisolated extension Database { cashAction: cashAction, cashReceiverID: row[m.cashReceiverId], cashPayments: Self.decodeCashPayments(row[m.cashPaymentsJson]), + cashReturn: Self.decodeCashReturn(offer: row[m.cashReturnOffer], returnOf: row[m.cashReturnOfId]), date: date, unreadSeq: row[m.unreadSeq], eventSequence: row[m.eventSequence], diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index c3d831aa7..889f7ab7d 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 64 + public static let schemaVersion = 65 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 0bd911e81..95914b275 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -142,6 +142,20 @@ nonisolated public struct RequestedPaymentTable: Sendable { public let paymentRequestID = Expression ("paymentRequestID") } +// Side table for payments that returned a DM's opening payment: the DM and the cash message returned. +// Joined 1:1 to `activity` by id; a row exists only for such a payment. A snapshot from payment time, +// so the message may since have been deleted. +nonisolated public struct ReturnedPaymentTable: Sendable { + public static let name = "returnedPayment" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let conversationID = Expression ("conversationID") + public let messageID = Expression ("messageID") +} + // One row per payment request, kept so a chat's request cards and details draw offline. The whole // `PaymentRequestMetadata` is stored serialized (private-chat text still sealed); `version`, `status` // and `conversationID` sit beside it as columns for the merge rule and the per-chat read. A request @@ -462,8 +476,13 @@ nonisolated public struct ConversationMessageTable: Sendable { public let systemInvitedBy = Expression ("systemInvitedBy") // JSON-encoded `[CashPayment]` of a multi-receiver cash row; nil for every other row. public let cashPaymentsJson = Expression ("cashPaymentsJson") - // The member a single-receiver group cash row paid; nil for DM cash and every other row. + // The member a single-receiver cash row paid (its `single_receiver`); nil for a multi-receiver + // row and every other row. public let cashReceiverId = Expression ("cashReceiverId") + // A cash row's `CashReturn`: whether its receiver is offered a return, and the message it + // returns. Both nil for a cash row that is neither, and for every other row. + public let cashReturnOffer = Expression ("cashReturnOffer") + public let cashReturnOfId = Expression ("cashReturnOfId") } @@ -590,6 +609,18 @@ nonisolated extension Database { }) } + let returnedPaymentTable = ReturnedPaymentTable() + + try writer.transaction(.immediate) { + try writer.run(returnedPaymentTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(returnedPaymentTable.id, primaryKey: true) + t.column(returnedPaymentTable.conversationID) + t.column(returnedPaymentTable.messageID) + + t.foreignKey(returnedPaymentTable.id, references: activityTable.table, activityTable.id, delete: .cascade) + }) + } + let paymentRequestTable = PaymentRequestTable() try writer.transaction(.immediate) { @@ -806,6 +837,8 @@ nonisolated extension Database { t.column(conversationMessageTable.systemInvitedBy) t.column(conversationMessageTable.cashPaymentsJson) t.column(conversationMessageTable.cashReceiverId) + t.column(conversationMessageTable.cashReturnOffer) + t.column(conversationMessageTable.cashReturnOfId) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 35075984f..c75e3a965 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -268,6 +268,33 @@ struct ConversationModelMappingTests { #expect(message.paymentRequestID?.data == Data(repeating: 0x07, count: 32)) } + @Test("Cash message maps its single receiver and its return offer and returned message") + func cashMessageMapsReturn() throws { + let receiver = Data(repeating: 0x05, count: 16) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 15 } + $0.content = [.with { + $0.cash = .with { + $0.totalAmount = .with { + $0.currency = "usd" + $0.nativeAmount = 1.0 + $0.quarks = 1_000_000 + $0.mint = .with { $0.value = Data(repeating: 0x02, count: 32) } + } + $0.singleReceiver = .with { $0.value = receiver } + $0.paymentReturn = .with { + $0.offer = .init() + $0.returnOf = .with { $0.messageID = .with { $0.value = 9 } } + } + } + }] + } + + let message = try #require(ConversationMessage(proto)) + #expect(message.cashReceiverID == (try UUID(data: receiver))) + #expect(message.cashReturn == CashReturn(hasOffer: true, returnOf: .init(messageID: MessageID(value: 9)))) + } + @Test("Viewer state maps silence and moderation permissions") func viewerStateMapsModeration() { let proto = Flipcash_Chat_V1_ViewerState.with { diff --git a/FlipcashTests/ActivityRowReturnTests.swift b/FlipcashTests/ActivityRowReturnTests.swift new file mode 100644 index 000000000..58a41d631 --- /dev/null +++ b/FlipcashTests/ActivityRowReturnTests.swift @@ -0,0 +1,71 @@ +// +// ActivityRowReturnTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Activity row for a sent-back payment") +struct ActivityRowReturnTests { + + private let dm = ConversationID.test(3) + + private func activity(_ kind: Activity.Kind, returned: Bool) -> Activity { + Activity( + id: .jeffy, + state: .completed, + kind: kind, + title: "Sent $5", + exchangedFiat: .mockOne, + date: .now, + metadata: nil, + returnedPayment: returned ? .init(conversationID: dm, messageID: MessageID(value: 1)) : nil + ) + } + + private func title(_ kind: Activity.Kind, name: String?) -> String? { + ActivityRow.title(kind: kind, boostedReaction: nil, name: name, chatName: nil, serverTitle: "Sent $5", isReturn: true) + } + + @Test("16: the side that sent it back reads 'Sent back to ' and opens the DM") + func sentSide() async { + #expect(title(.gave, name: "Alex Morgan") == "Sent back to Alex Morgan") + #expect(await ActivityRow.destination(for: activity(.gave, returned: true)) { _ in true } == .tipConversation(dm)) + } + + @Test("17: the side it was returned to reads ' sent back' and opens the DM") + func receivedSide() async { + #expect(title(.received, name: "Alex Morgan") == "Alex Morgan sent back") + #expect(await ActivityRow.destination(for: activity(.received, returned: true)) { _ in true } == .tipConversation(dm)) + } + + @Test("An unresolved name falls back to the server's title") + func unknownName() { + #expect(title(.gave, name: nil) == nil) + #expect(title(.received, name: "") == nil) + } + + @Test("16/17: the DM is looked up by the return's chat id, so one not stored yet is fetched before falling back") + func looksUpTheReturnsDM() async { + var asked: [ConversationID] = [] + let destination = await ActivityRow.destination(for: activity(.received, returned: true)) { + asked.append($0) + return true + } + #expect(asked == [dm]) + #expect(destination == .tipConversation(dm)) + } + + @Test("A return whose DM is gone or can't be fetched, and any other row, opens the details screen") + func detailsFallback() async { + let gone = activity(.gave, returned: true) + #expect(await ActivityRow.destination(for: gone) { _ in false } == .transactionDetails(gone)) + let plain = activity(.gave, returned: false) + #expect(await ActivityRow.destination(for: plain) { _ in true } == .transactionDetails(plain)) + } +} diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index ece404cec..65ee64135 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -45,6 +45,38 @@ struct ChatCashCardCellTests { #expect(measure(group) > measure(dm)) } + private func offered(_ state: ChatCashContent.SendBack?) -> ChatMessage { + ChatMessage(id: "3", content: .cash(ChatCashContent( + amount: "$5.00", token: "Cash", + lines: .init(caption: "Noah Parker sent", counterpart: "to you"), + sendBack: state + )), sender: .other) + } + + @Test("A Send Back row adds to the card's height in every state") + func sendBackRow_growsCard() { + let plain = measure(offered(nil)) + for state in [ChatCashContent.SendBack.available, .sending, .sentBack] { + #expect(measure(offered(state)) > plain) + } + } + + @Test("The Send Back row is 44 tall under the card's padding; the Sent Back footer is 36 in place of it") + func sendBackRow_sizes() { + let plain = measure(offered(nil)) + #expect((measure(offered(.available)) - plain).rounded() == 44) + #expect((measure(offered(.sending)) - plain).rounded() == 44) + // 36 for the footer, less the 16 bottom padding it replaces. + #expect((measure(offered(.sentBack)) - plain).rounded() == 20) + } + + @Test("A cell recycled from an offered card to a plain one drops the row") + func reusedCell_sendBackToPlain_measuresFresh() { + let cell = ChatCashCardCell(frame: .zero) + _ = measure(offered(.sending), in: cell) + #expect(measure(offered(nil), in: cell) == measure(offered(nil))) + } + @Test("A cell recycled from a group card to a DM card measures as a fresh one") func reusedCell_groupToDM_measuresFresh() { let cell = ChatCashCardCell(frame: .zero) diff --git a/FlipcashTests/Chat/ChatSendBackStageTests.swift b/FlipcashTests/Chat/ChatSendBackStageTests.swift new file mode 100644 index 000000000..b2f7b99e0 --- /dev/null +++ b/FlipcashTests/Chat/ChatSendBackStageTests.swift @@ -0,0 +1,55 @@ +// +// ChatSendBackStageTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore +@testable import FlipcashUI + +/// A Send Back row or footer appears without animating its bubble: the transcript applies those +/// cards' changes first, in place and with animation off, before the rest of the push. +@MainActor +@Suite("Chat Send Back stage") +struct ChatSendBackStageTests { + + private func cash(_ id: String, _ sendBack: ChatCashContent.SendBack?, sender: ChatMessage.Sender = .other) -> ChatItem { + .message(ChatMessage(id: id, content: .cash(ChatCashContent( + amount: "$5.00", token: "Cash", + lines: .init(caption: "Noah Parker sent", counterpart: "to you"), + sendBack: sendBack + )), sender: sender)) + } + + private func text(_ id: String) -> ChatItem { + .message(ChatMessage(id: id, text: "text-\(id)", sender: .me)) + } + + @Test("The status check resolving to Available stages the whole push, which reconfigures in place") + func rowAppears_stagesWholePush() { + let before = [cash("a", nil), text("b")] + let after = [cash("a", .available), text("b")] + #expect(ChatViewController.sendBackStage(from: before, to: after) == after) + } + + @Test("A return arriving with the original's footer stages the footer alone, keeping the old rows") + func footerWithArrival_stagesFooterOnly() { + let before = [cash("a", nil, sender: .me)] + let after = [cash("a", .sentBack, sender: .me), cash("r", nil)] + #expect(ChatViewController.sendBackStage(from: before, to: after) == [cash("a", .sentBack, sender: .me)]) + } + + @Test("Sending turning to Sent Back, a resize, is staged") + func sendingToSentBack_isStaged() { + #expect(ChatViewController.sendBackStage(from: [cash("a", .sending)], to: [cash("a", .sentBack)]) != nil) + } + + @Test("A push with no Send Back change stages nothing") + func otherChange_stagesNothing() { + let before = [cash("a", .available), text("b")] + let after = [cash("a", .available), text("b"), text("c")] + #expect(ChatViewController.sendBackStage(from: before, to: after) == nil) + } +} diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift new file mode 100644 index 000000000..2eb760537 --- /dev/null +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -0,0 +1,531 @@ +// +// SendBackTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +// MARK: - Fixtures - + +private let me = UserID() +private let alex = UserID() +private let chat = ConversationID.test(7) + +private let five = ExchangedFiat( + onChainAmount: TokenAmount(quarks: 5_000_000, mint: .usdf), + nativeAmount: FiatAmount(value: 5, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) +) + +private func cash( + _ id: UInt64, + from sender: UserID, + to receiver: UserID?, + _ cashReturn: CashReturn? = nil +) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: sender, + content: .cash(five), + cashReceiverID: receiver, + cashReturn: cashReturn, + date: Date(timeIntervalSince1970: TimeInterval(id)), + unreadSeq: id + ) +} + +private let offer = CashReturn(hasOffer: true, returnOf: nil) +private func returning(_ id: UInt64) -> CashReturn { + CashReturn(hasOffer: false, returnOf: .init(messageID: MessageID(value: id))) +} + +// MARK: - Row - + +@Suite("Send Back row") +struct SendBackRowTests { + + private func row(_ message: ConversationMessage, in window: [ConversationMessage]? = nil, viewer: UserID = me, local: SendBackRow.Local = .unknown) -> ChatCashContent.SendBack? { + SendBackRow.resolve(message, selfUserID: viewer, returnedIDs: SendBackRow.returnedIDs(in: window ?? [message]), local: local) + } + + private func needsStatus(_ message: ConversationMessage, in window: [ConversationMessage]? = nil, viewer: UserID = me) -> Bool { + SendBackRow.needsStatus(message, selfUserID: viewer, returnedIDs: SendBackRow.returnedIDs(in: window ?? [message])) + } + + @Test("1: an offer to the viewer whose status is AVAILABLE shows Send Back") + func receiver_available() { + let original = cash(1, from: alex, to: me, offer) + #expect(needsStatus(original)) + #expect(row(original, local: .available) == .available) + } + + @Test("2: the sender of an offer sees no row and asks nothing") + func sender_noRow() { + let original = cash(1, from: me, to: alex, offer) + #expect(!needsStatus(original)) + #expect(row(original, local: .available) == nil) + } + + @Test("3: a cash message with no offer shows no row and asks nothing") + func noOffer() { + let plain = cash(1, from: alex, to: me) + #expect(!needsStatus(plain)) + #expect(row(plain, local: .available) == nil) + } + + @Test("4: a later message returning the offer shows Sent Back and skips the status check") + func receiver_returnedInTranscript() { + let original = cash(1, from: alex, to: me, offer) + let window = [original, cash(2, from: me, to: alex, returning(1))] + #expect(!needsStatus(original, in: window)) + #expect(row(original, in: window) == .sentBack) + } + + @Test("5: status RETURNED shows Sent Back") + func receiver_statusReturned() { + #expect(row(cash(1, from: alex, to: me, offer), local: .sentBack) == .sentBack) + } + + @Test("6: the original sender sees Sent Back on their bubble once a later message returns it") + func sender_returnedInTranscript() { + let original = cash(1, from: me, to: alex, offer) + let window = [original, cash(2, from: alex, to: me, returning(1))] + #expect(!needsStatus(original, in: window)) + #expect(row(original, in: window) == .sentBack) + } + + @Test("While the status is unknown the row stays hidden; Sending shows the spinner state") + func receiver_unknownAndSending() { + let original = cash(1, from: alex, to: me, offer) + #expect(row(original, local: .unknown) == nil) + #expect(row(original, local: .sending) == .sending) + } + + @Test("A return message that also carries an offer follows the offered-bubble rules") + func returnWithOffer() { + let both = cash(3, from: alex, to: me, CashReturn(hasOffer: true, returnOf: .init(messageID: MessageID(value: 1)))) + #expect(needsStatus(both)) + #expect(row(both, local: .available) == .available) + } +} + +// MARK: - Return captions - + +@Suite("Send Back return captions") +struct SendBackCaptionTests { + + private func lines(_ message: ConversationMessage) -> ChatCashContent.Lines? { + ChatItem.from([message], selfUserID: me, counterpartName: "Alex Morgan").compactMap { + if case .message(let m) = $0, case .cash(let cash) = m.content { cash.lines } else { nil } + }.first + } + + @Test("8: the sender of a return reads 'You sent back' / 'to '") + func returnSender() { + let line = lines(cash(2, from: me, to: alex, returning(1))) + #expect(line?.caption == "You sent back") + #expect(line?.counterpart == "to Alex Morgan") + } + + @Test("9: the receiver of a return reads ' sent back' / 'to you'") + func returnReceiver() { + let line = lines(cash(2, from: alex, to: me, returning(1))) + #expect(line?.caption == "Alex Morgan sent back") + #expect(line?.counterpart == "to you") + } + + @Test("A return draws no Send Back row of its own") + func returnHasNoRow() { + let message = cash(2, from: alex, to: me, returning(1)) + #expect(SendBackRow.resolve(message, selfUserID: me, returnedIDs: [], local: .available) == nil) + } +} + +// MARK: - Controller - + +@MainActor +private final class FakeServices: SendBackServicing { + var statuses: [CashReturnStatus.State] = [] + var statusError: Error? + var statusCalls = 0 + var shortfall: ExchangedFiat?? = nil + var payError: Error? + var withholdIntent = false + var owner = PublicKey.testMint(index: 42) + struct Payment { let amount: ExchangedFiat; let to: PublicKey; let chatID: ConversationID; let messageID: MessageID; let intentID: PublicKey } + var payments: [Payment] = [] + static let intent = PublicKey.testMint(index: 99) + + func cashMessageReturnStatus(conversationID: ConversationID, messageID: MessageID) async throws -> CashReturnStatus { + statusCalls += 1 + if let statusError { throw statusError } + let state = statuses.isEmpty ? .available : statuses.removeFirst() + return CashReturnStatus(state: state, paymentIntentID: state == .available && !withholdIntent ? Self.intent.data : nil) + } + + /// Quarks held; when unset, enough unless `shortfall` is set. + var held: UInt64? + /// What the session's tolerant check answers; when unset, it follows `shortfall`. + var sessionCheck: Session.SufficientFundsResult? + var token: (name: String, iconURL: URL?)? = ("USDF", URL(string: "https://example.com/usdf.png")) + var priceCalls = 0 + var holdPrice = false + /// No fresh rate: `priceReturn` answers nil. + var noRate = false + /// No balance of the mint is loaded: `heldQuarks` answers nil. + var balanceUnloaded = false + private var priceRelease: CheckedContinuation? + + func releasePrice() { priceRelease?.resume(); priceRelease = nil } + + func priceReturn(of original: ExchangedFiat) async -> (amount: ExchangedFiat, pin: VerifiedState)? { + priceCalls += 1 + if holdPrice { await withCheckedContinuation { priceRelease = $0 } } + if noRate { return nil } + return (original, .stale(bonded: false)) + } + + func heldQuarks(of mint: PublicKey) -> UInt64? { + if balanceUnloaded { return nil } + return held ?? (shortfall == nil ? 10_000_000 : 0) + } + + func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult { + if let sessionCheck { return sessionCheck } + if let shortfall { return .insufficient(shortfall: shortfall) } + return .sufficient(amountToSend: amount) + } + + func tokenLabel(for mint: PublicKey) async -> (name: String, iconURL: URL?)? { token } + + func resolveUserID(_ userID: UserID) async throws -> PublicKey { owner } + + func returnPayment(amount: ExchangedFiat, verifiedState: VerifiedState, to senderOwner: PublicKey, chatID: ConversationID, messageID: MessageID, intentID: PublicKey) async throws { + payments.append(.init(amount: amount, to: senderOwner, chatID: chatID, messageID: messageID, intentID: intentID)) + if let payError { throw payError } + } +} + +@MainActor +private final class FakeAnalytics: SendBackAnalytics { + var taps: [Bool] = [] + var sent: [String] = [] + func tapped(hasBalance: Bool) { taps.append(hasBalance) } + func sent(_ outcome: SendBackOutcome, amount: ExchangedFiat) { + switch outcome { + case .success: sent.append("success") + case .alreadyReturned: sent.append("alreadyReturned") + case .failed: sent.append("failed") + } + } +} + +@MainActor +private final class FakePresenter: SendBackPresenting { + var shown: [String] = [] + func showInsufficientBalance(shortfall: ExchangedFiat?, mint: PublicKey) { shown.append("insufficient") } + func showSendBackError() { shown.append("error") } + func showRateUnavailable() { shown.append("rate") } +} + +private struct Boom: Error {} + +@MainActor +@Suite("Send Back controller") +struct SendBackControllerTests { + + fileprivate let services = FakeServices() + fileprivate let analytics = FakeAnalytics() + fileprivate let presenter = FakePresenter() + let messageID = MessageID(value: 1) + + private func controller() -> SendBackController { + SendBackController(conversationID: chat, services: services, analytics: analytics, presenter: presenter) + } + + private var sendBackOffer: SendBackOffer { + SendBackOffer(conversationID: chat, messageID: messageID, senderID: alex, original: five, recipientName: "Alex Morgan", token: "USD Coin", iconURL: nil) + } + + /// Runs the open's status check and returns a controller whose row is `state`. + private func opened() async -> SendBackController { + let controller = controller() + controller.checkStatusIfNeeded(messageID) + await controller.statusTask?.value + return controller + } + + @Test("1: the open's status check asks once and AVAILABLE makes the row available") + func statusAvailable() async { + let controller = await opened() + controller.checkStatusIfNeeded(messageID) + #expect(services.statusCalls == 1) + #expect(controller.local(for: messageID) == .available) + } + + @Test("5: status RETURNED makes the row Sent Back") + func statusReturned() async { + services.statuses = [.returned] + let controller = await opened() + #expect(controller.local(for: messageID) == .sentBack) + } + + @Test("7: a failed status check leaves the row hidden and shows nothing") + func statusFails() async { + services.statusError = Boom() + let controller = await opened() + #expect(controller.local(for: messageID) == .unknown) + #expect(presenter.shown.isEmpty) + } + + @Test("10: a tap with enough balance confirms the offer, naming the recipient, and reports Has Balance") + func tapWithBalance() async { + let controller = await opened() + let confirmed = await controller.tap(sendBackOffer) + #expect(confirmed?.recipientName == "Alex Morgan") + #expect(analytics.taps == [true]) + #expect(presenter.shown.isEmpty) + } + + @Test("11: a tap short of balance opens no sheet, shows the insufficient-balance modal, and reports no balance") + func tapShort() async { + let controller = await opened() + services.shortfall = .some(five) + #expect(await controller.tap(sendBackOffer) == nil) + #expect(presenter.shown == ["insufficient"]) + #expect(analytics.taps == [false]) + } + + @Test("11: a balance a fraction of a cent short is short, even where the session's tolerance would let it through") + func tapShortByAFraction() async { + let controller = await opened() + services.held = five.onChainAmount.quarks - 1 + services.sessionCheck = .sufficient(amountToSend: five) + #expect(await controller.tap(sendBackOffer) == nil) + #expect(presenter.shown == ["insufficient"]) + #expect(analytics.taps == [false]) + } + + @Test("11: a balance of exactly the needed quarks is enough") + func tapExactBalance() async { + let controller = await opened() + services.held = five.onChainAmount.quarks + #expect(await controller.tap(sendBackOffer) != nil) + #expect(analytics.taps == [true]) + } + + @Test("10: a second tap while the first is in flight does nothing: one Tapped event, one sheet") + func doubleTap() async { + let controller = await opened() + services.holdPrice = true + let first = Task { await controller.tap(sendBackOffer) } + while services.priceCalls == 0 { await Task.yield() } + #expect(await controller.tap(sendBackOffer) == nil) + services.releasePrice() + #expect(await first.value != nil) + #expect(analytics.taps == [true]) + #expect(services.priceCalls == 1) + // The guard clears once the tap resolves. + services.holdPrice = false + #expect(await controller.tap(sendBackOffer) != nil) + } + + @Test("10: the sheet names the token and shows its icon from the mint's metadata, not the balance") + func tokenFromMetadata() async { + let controller = await opened() + let confirmed = await controller.tap(sendBackOffer) + #expect(confirmed?.token == "USDF") + #expect(confirmed?.iconURL == URL(string: "https://example.com/usdf.png")) + } + + @Test("10: without mint metadata, the sheet keeps the name it was given") + func tokenWithoutMetadata() async { + let controller = await opened() + services.token = nil + let confirmed = await controller.tap(sendBackOffer) + #expect(confirmed?.token == "USD Coin") + } + + @Test("12: the return pays the original's amount to the sender's owner, in the DM, under the server's intent id") + func returnIntent() async throws { + let controller = await opened() + #expect(await controller.confirm(sendBackOffer) == .submitted) + await controller.submitTask?.value + let payment = try #require(services.payments.first) + #expect(payment.messageID == messageID) + #expect(payment.chatID == chat) + #expect(payment.to == services.owner) + #expect(payment.amount.mint == five.mint) + #expect(payment.amount.nativeAmount == five.nativeAmount) + #expect(payment.intentID == FakeServices.intent) + } + + @Test("13: a swipe shows Sending, then Sent Back on success, and reports Success") + func submitSucceeds() async { + let controller = await opened() + _ = await controller.confirm(sendBackOffer) + #expect(controller.local(for: messageID) == .sending) + await controller.submitTask?.value + #expect(controller.local(for: messageID) == .sentBack) + #expect(analytics.sent == ["success"]) + } + + @Test("14: a failed submit that the re-check finds RETURNED shows Sent Back, no error, Already Returned") + func submitFailsAlreadyReturned() async { + let controller = await opened() + services.payError = Boom() + services.statuses = [.returned] + _ = await controller.confirm(sendBackOffer) + await controller.submitTask?.value + #expect(controller.local(for: messageID) == .sentBack) + #expect(presenter.shown.isEmpty) + #expect(analytics.sent == ["alreadyReturned"]) + } + + @Test("15: a failed submit that the re-check finds AVAILABLE returns the row and shows Couldn't Send Back") + func submitFailsAvailable() async { + let controller = await opened() + services.payError = Boom() + services.statuses = [.available] + _ = await controller.confirm(sendBackOffer) + await controller.submitTask?.value + #expect(controller.local(for: messageID) == .available) + #expect(presenter.shown == ["error"]) + #expect(analytics.sent == ["failed"]) + #expect(services.statusCalls == 2) + } + + @Test("Step 1: with no intent id from the open, the swipe asks; RETURNED is Sent Back, Already Returned, no payment") + func submitNoIntentReturned() async { + services.withholdIntent = true + let controller = await opened() + services.statuses = [.returned] + _ = await controller.confirm(sendBackOffer) + await controller.submitTask?.value + #expect(controller.local(for: messageID) == .sentBack) + #expect(services.payments.isEmpty) + #expect(analytics.sent == ["alreadyReturned"]) + #expect(presenter.shown.isEmpty) + } + + @Test("10/11: a tap with no fresh rate shows Rate Unavailable, opens no sheet, and reports no Tapped event") + func tapWithoutRate_reportsNothing() async { + let controller = await opened() + services.noRate = true + #expect(await controller.tap(sendBackOffer) == nil) + #expect(presenter.shown == ["rate"]) + #expect(analytics.taps.isEmpty) + } + + @Test("10/11: a tap whose balance isn't loaded shows Rate Unavailable, opens no sheet, and reports no Tapped event") + func tapWithBalanceUnloaded_reportsNothing() async { + let controller = await opened() + services.balanceUnloaded = true + #expect(await controller.tap(sendBackOffer) == nil) + #expect(presenter.shown == ["rate"]) + #expect(analytics.taps.isEmpty) + } + + @Test("19: a swipe whose balance isn't loaded submits nothing and shows Rate Unavailable") + func swipeWithBalanceUnloaded_blocks() async { + let controller = await opened() + #expect(await controller.tap(sendBackOffer) != nil) + services.balanceUnloaded = true + #expect(await controller.confirm(sendBackOffer) == .blocked) + #expect(controller.local(for: messageID) == .available) + #expect(presenter.shown == ["rate"]) + #expect(services.payments.isEmpty) + } + + @Test("19: the balance dropping while the sheet is open blocks the swipe: no submit, row Available, modal") + func balanceDropsBeforeSwipe() async { + let controller = await opened() + #expect(await controller.tap(sendBackOffer) != nil) + services.shortfall = .some(nil) + #expect(await controller.confirm(sendBackOffer) == .blocked) + #expect(controller.local(for: messageID) == .available) + #expect(presenter.shown == ["insufficient"]) + #expect(services.payments.isEmpty) + #expect(controller.submitTask == nil) + } + + @Test("19: a balance that drops a fraction of a cent short while the sheet is open still blocks the swipe") + func balanceDropsByAFractionBeforeSwipe() async { + let controller = await opened() + #expect(await controller.tap(sendBackOffer) != nil) + services.held = five.onChainAmount.quarks - 1 + services.sessionCheck = .sufficient(amountToSend: five) + #expect(await controller.confirm(sendBackOffer) == .blocked) + #expect(controller.local(for: messageID) == .available) + #expect(presenter.shown == ["insufficient"]) + #expect(services.payments.isEmpty) + #expect(controller.submitTask == nil) + } +} + +// MARK: - Intent id privacy - + +@Suite("Send Back intent id privacy") +struct SendBackPrivacyTests { + + @Test("18: a server-issued intent id never reaches the payment logs, and a failed return reports as Send back") + func intentNeverLogged() { + let intent = PublicKey.testMint(index: 99) + #expect(Session.rendezvousLogValue(intent, serverIssued: true) == "server-issued") + #expect(!Session.rendezvousLogValue(intent, serverIssued: true).contains(intent.base58)) + #expect(Session.rendezvousLogValue(intent, serverIssued: false) == intent.base58) + #expect(Session.serverIssuedFailureReason(.returnPayment(chatID: chat, messageID: MessageID(value: 1))) == "Send back failed") + } +} + +// MARK: - Analytics - + +@MainActor +@Suite("Send Back analytics") +struct SendBackAnalyticsTests { + + private let analytics = LiveSendBackAnalytics() + + @Test("10/11: a tap reports Send Back: Tapped with Has Balance") + func tapped() { + let sent = Analytics.recordingSends { + analytics.tapped(hasBalance: true) + analytics.tapped(hasBalance: false) + } + #expect(sent.map(\.name) == ["Send Back: Tapped", "Send Back: Tapped"]) + #expect(sent.map { $0.properties["Has Balance"] as? Bool } == [true, false]) + } + + @Test("13/14/15: Sent Cash reports the Send Back context, its State and Error, and no Recipient Count") + func sentCash() throws { + let sent = Analytics.recordingSends { + analytics.sent(.success, amount: five) + analytics.sent(.alreadyReturned, amount: five) + analytics.sent(.failed(Boom()), amount: five) + } + #expect(sent.map(\.name) == ["Sent Cash", "Sent Cash", "Sent Cash"]) + #expect(sent.allSatisfy { $0.properties["Context"] as? String == "Send Back" }) + #expect(sent.allSatisfy { $0.properties["Recipient Count"] == nil }) + #expect(sent.map { $0.properties["State"] as? String } == ["Success", "Failure", "Failure"]) + #expect(sent[0].properties["Error"] == nil) + #expect(sent[1].properties["Error"] as? String == "Already Returned") + let error = try #require(sent[2].properties["Error"] as? String) + #expect(error.contains("Boom")) + } + + @Test("18: no Send Back event carries an intent, chat or message id") + func noIDs() { + let sent = Analytics.recordingSends { + analytics.tapped(hasBalance: true) + analytics.sent(.success, amount: five) + } + let values = sent.flatMap(\.properties.values).map { "\($0)" } + #expect(!values.contains { $0.contains(FakeServices.intent.base58) }) + #expect(!values.contains { $0.contains(chat.description) }) + } +} diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 66b0d769f..73d8bcdf7 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -166,6 +166,35 @@ struct DatabaseConversationsTests { #expect(loaded.map(\.cashReceiverID) == [receiver, nil]) } + @Test("A cash message's return offer and returned message round-trip, and a plain cash message reads back nil") + func cashReturnRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + func cash(_ n: UInt64, _ cashReturn: CashReturn?) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: n), + senderID: UUID(), + content: .cash(ExchangedFiat( + onChainAmount: TokenAmount(quarks: 1_000_000, mint: .usdf), + nativeAmount: FiatAmount(value: 1, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + )), + cashReturn: cashReturn, + date: Date(timeIntervalSince1970: TimeInterval(n)), + unreadSeq: n + ) + } + let offer = CashReturn(hasOffer: true, returnOf: nil) + let returning = CashReturn(hasOffer: false, returnOf: .init(messageID: MessageID(value: 1))) + let both = CashReturn(hasOffer: true, returnOf: .init(messageID: MessageID(value: 2))) + + try database.upsertConversationMessages([cash(1, offer), cash(2, returning), cash(3, both), cash(4, nil)], conversationID: id) + let loaded = try database.getConversationMessages(conversationID: id) + + #expect(loaded.map(\.cashReturn) == [offer, returning, both, nil]) + } + @Test("Widget messages round-trip, including a payment request, and a widget with no username reads back as unrecognized") func widgetMessageRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/Database/Database+ReturnedPaymentTests.swift b/FlipcashTests/Database/Database+ReturnedPaymentTests.swift new file mode 100644 index 000000000..0e66a126a --- /dev/null +++ b/FlipcashTests/Database/Database+ReturnedPaymentTests.swift @@ -0,0 +1,54 @@ +// +// Database+ReturnedPaymentTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Database+ReturnedPayment") +struct DatabaseReturnedPaymentTests { + + private static let payment = Activity.ReturnedPayment( + conversationID: ConversationID(data: Data((0..<16).map(UInt8.init))), + messageID: MessageID(value: 42) + ) + + private static func activity(payment: Activity.ReturnedPayment?) -> Activity { + Activity( + id: .jeffy, + state: .completed, + kind: .gave, + title: "Sent", + exchangedFiat: .mockOne, + date: .now, + metadata: nil, + returnedPayment: payment + ) + } + + @Test("A return reads back with the same DM and message, per mint and recent") + func roundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + + #expect(try database.getRecentActivities(limit: 10).first?.returnedPayment == Self.payment) + #expect(try database.getActivities(mint: ExchangedFiat.mockOne.mint).first?.returnedPayment == Self.payment) + } + + @Test("Re-inserting an activity without its return removes the stored link") + func resyncDropsLink() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(payment: Self.payment)]) + try database.insertActivities(activities: [Self.activity(payment: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.returnedPayment == nil) + } +} diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index f5089eb3e..36d8d6e2e 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -37,7 +37,7 @@ let sharedCore: Package.Dependency = { } return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.19.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.20.0")) }() let package = Package( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index ea83a8642..5252844e6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -25,7 +25,8 @@ public final class ChatCashCardCell: ChatColumnCell { private static let eachFont = UIFont.default(size: amountFont.pointSize / 2, weight: .medium) // The "~" before the smallest of uneven parts: the amount's weight at 70% of its size. private static let approximateFont = UIFont.default(size: (amountFont.pointSize * 0.7).rounded(), weight: .bold) - private static let secondaryColor = UIColor.white.withAlphaComponent(0.5) + /// The card's secondary lines; the Send Back footer uses it too. + static let secondaryColor = UIColor.white.withAlphaComponent(0.5) // Vertical spacing, per "Spacing values" in docs/specs/cash-bubbles.md. Frame gaps, so both // apps give the same content the same card height. @@ -46,6 +47,10 @@ public final class ChatCashCardCell: ChatColumnCell { private let tokenRow = UIStackView() private let counterpartLabel = UILabel() private let centerStack = UIStackView() + private let sendBackRow = SendBackRowView() + /// The counterpart line's gap to the card's foot, or to the Send Back row when one is drawn. + private var counterpartToCardBottom: NSLayoutConstraint! + private var counterpartToSendBackRow: NSLayoutConstraint! private var cardWidthConstraint: NSLayoutConstraint! private var reactionRowWidthConstraint: NSLayoutConstraint! @@ -101,6 +106,10 @@ public final class ChatCashCardCell: ChatColumnCell { centerStack.translatesAutoresizingMaskIntoConstraints = false card.addSubview(centerStack) + sendBackRow.translatesAutoresizingMaskIntoConstraints = false + sendBackRow.addTarget(self, action: #selector(sendBackTapped), for: .touchUpInside) + card.addSubview(sendBackRow) + installColumn(content: card, accessory: reactionRow) reactionRow.onToggle = { [weak self] emoji in self?.onReactionTap?(emoji) } reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } @@ -121,10 +130,20 @@ public final class ChatCashCardCell: ChatColumnCell { counterpartLabel.topAnchor.constraint(equalTo: centerStack.bottomAnchor, constant: Self.blockSpacing), counterpartLabel.centerXAnchor.constraint(equalTo: card.centerXAnchor), - counterpartLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -Self.verticalInset), counterpartLabel.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16), counterpartLabel.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16), + + sendBackRow.leadingAnchor.constraint(equalTo: card.leadingAnchor), + sendBackRow.trailingAnchor.constraint(equalTo: card.trailingAnchor), + sendBackRow.bottomAnchor.constraint(equalTo: card.bottomAnchor), ]) + counterpartToCardBottom = counterpartLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -Self.verticalInset) + counterpartToSendBackRow = counterpartLabel.bottomAnchor.constraint(equalTo: sendBackRow.topAnchor, constant: -Self.verticalInset) + counterpartToCardBottom.isActive = true + } + + @objc private func sendBackTapped() { + onSendBack?() } @available(*, unavailable) @@ -146,6 +165,8 @@ public final class ChatCashCardCell: ChatColumnCell { var onReactionAdd: (() -> Void)? /// Called when the viewer taps the leading "$" to pay for a reaction. var onReactionBoost: (() -> Void)? + /// Called when the receiver taps Send Back on a DM's opening payment. + var onSendBack: (() -> Void)? private func configureCircle(_ imageView: UIImageView, diameter: CGFloat) { imageView.contentMode = .scaleAspectFill @@ -186,6 +207,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.setImage(with: cash.iconURL) counterpartLabel.text = cash.lines?.counterpart counterpartLabel.isHidden = cash.lines == nil + applySendBack(cash.sendBack) card.apply( fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), @@ -207,6 +229,19 @@ public final class ChatCashCardCell: ChatColumnCell { card.alpha = isHighlighted ? 0.6 : 1 } + /// Shows the Send Back row for `state`, or none. Read from the message on every configure, so a + /// reused cell never keeps another row's spinner. + private func applySendBack(_ state: ChatCashContent.SendBack?) { + sendBackRow.isHidden = state == nil + if let state { sendBackRow.apply(state) } + // The Sent Back footer replaces the card's bottom padding; the action row sits under it. + counterpartToSendBackRow.constant = state == .sentBack ? 0 : -Self.verticalInset + // Deactivate before activating, so the two never hold the label at once. + counterpartToCardBottom.isActive = false + counterpartToSendBackRow.isActive = false + (state == nil ? counterpartToCardBottom : counterpartToSendBackRow).isActive = true + } + /// The amount, followed by a smaller " each" when it is one person's part of a group send, and /// led by a smaller "~" when it is the smallest of uneven parts. One string, so the runs share /// a baseline and the label's auto-size shrinks them together, and "each" never wraps. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 3960ab418..cd0e31875 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -204,6 +204,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onPaymentRequestTap = newValue } } + /// Called with a cash message's id when its receiver taps Send Back. + public var onSendBack: ((String) -> Void)? { + get { transcript.onSendBack } + set { transcript.onSendBack = newValue } + } + public var onOpenURL: ((URL) -> Void)? { get { transcript.onOpenURL } set { transcript.onOpenURL = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 380c64bf4..4008f95de 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -55,6 +55,9 @@ public final class ChatViewController: UICollectionViewController { /// owner opens the request's details. public var onPaymentRequestTap: ((Data) -> Void)? + /// Called when the receiver taps Send Back on a cash card; the argument is the message's id. + public var onSendBack: ((String) -> Void)? + /// Called when the user taps a URL in a text bubble; the owner opens it. public var onOpenURL: ((URL) -> Void)? /// Fired when a shared-profile widget's Share button is tapped, with the person's display name @@ -534,6 +537,13 @@ public final class ChatViewController: UICollectionViewController { return } + // A Send Back row or footer appears without animating its bubble (send-back spec): those + // cards change first, in a pass of their own with animation off, and anything else in this + // push (the return message arriving, say) then rides the spring as usual. + if let staged = Self.sendBackStage(from: items, to: newItems) { + applyWithoutAnimation(staged) + } + let changeset = StagedChangeset(source: items, target: newItems) guard !changeset.isEmpty else { items = newItems @@ -674,6 +684,38 @@ public final class ChatViewController: UICollectionViewController { /// - Parameter old: the rows before the update. When the update only removes the group-ready /// card, the rows below slide into its space on `inviteCardExit`: an arrival's bounce would read /// as the conversation lurching rather than settling. + /// `old` with every cash card whose Send Back state differs in `new` swapped for its `new` + /// version, or `nil` when no card's Send Back state changed. Same rows in the same order as + /// `old`, so applying it is an in-place reconfigure only. + static func sendBackStage(from old: [ChatItem], to new: [ChatItem]) -> [ChatItem]? { + guard !old.isEmpty else { return nil } + let newByID = Dictionary(new.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var changed = false + let staged = old.map { item -> ChatItem in + guard case .message(let before) = item, case .cash(let beforeCash) = before.content, + let next = newByID[item.id], case .message(let after) = next, case .cash(let afterCash) = after.content, + beforeCash.sendBack != afterCash.sendBack + else { return item } + changed = true + return next + } + return changed ? staged : nil + } + + /// Reconfigures rows in place with UIKit animation off, so a resized card snaps to its new height. + private func applyWithoutAnimation(_ staged: [ChatItem]) { + let changeset = StagedChangeset(source: items, target: staged) + guard !changeset.isEmpty else { return } + UIView.performWithoutAnimation { + collectionView.reload( + using: changeset, + reconfiguring: { [weak self] in self?.isReconfiguring = $0 }, + setData: { [weak self] data in self?.items = data } + ) + collectionView.layoutIfNeeded() + } + } + static func batchSpring(for changeset: StagedChangeset<[ChatItem]>, from old: [ChatItem] = []) -> ChatSpring { if removesOnlyInviteCard(changeset, from: old) { return ChatMotion.inviteCardExit } let changesRows = changeset.contains { stage in @@ -822,6 +864,7 @@ public final class ChatViewController: UICollectionViewController { // Every cash card is one width, so an incoming card in a group doesn't give up the avatar // gutter the way a bubble does. cell.configure(with: message, maxWidth: cashCardWidth(available: width), authorImageData: authorImageData) + cell.onSendBack = { [weak self] in self?.onSendBack?(message.messageID) } cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift new file mode 100644 index 000000000..c9727a462 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -0,0 +1,104 @@ +// +// SendBackRowView.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI +import FlipcashCore + +/// The full-width row a cash card attaches to its bottom edge under a DM's opening payment: a white +/// "Send Back" action, a spinner while the return is sending, or a quiet "Sent Back" footer. The +/// card's shape mask rounds its lower corners. +final class SendBackRowView: UIControl { + + /// The action row's height, Available and Sending (spec: 44). + static let actionHeight: CGFloat = 44 + /// The Sent Back footer's height. It takes the place of the card's bottom padding (spec: 36). + static let footerHeight: CGFloat = 36 + + static func height(for state: ChatCashContent.SendBack) -> CGFloat { + state == .sentBack ? footerHeight : actionHeight + } + + private static let actionFill = UIColor(Color.textMain) + private static let actionText = UIColor(Color.backgroundMain) + /// The card's caption style, so "Sent Back" reads as one of the card's lines. + private static let footerFont = UIFont.appTextHeading + private static let footerColor = ChatCashCardCell.secondaryColor + + private let label = UILabel() + private let spinner = UIActivityIndicatorView(style: .medium) + private var heightConstraint: NSLayoutConstraint! + /// The state last applied; read by tests. + private(set) var displayedState: ChatCashContent.SendBack = .available + + override init(frame: CGRect) { + super.init(frame: frame) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + spinner.color = Self.actionText + spinner.hidesWhenStopped = true + spinner.translatesAutoresizingMaskIntoConstraints = false + [label, spinner].forEach(addSubview) + isAccessibilityElement = true + + heightConstraint = heightAnchor.constraint(equalToConstant: Self.actionHeight) + NSLayoutConstraint.activate([ + heightConstraint, + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + spinner.centerXAnchor.constraint(equalTo: centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Dims the action while pressed, as the card dims for its own tap. + override var isHighlighted: Bool { + didSet { alpha = isHighlighted && isEnabled ? 0.6 : 1 } + } + + func apply(_ state: ChatCashContent.SendBack) { + displayedState = state + heightConstraint.constant = Self.height(for: state) + switch state { + case .available: + backgroundColor = Self.actionFill + label.isHidden = false + label.text = "Send Back" + label.font = .appTextSmall + label.textColor = Self.actionText + spinner.stopAnimating() + isEnabled = true + accessibilityLabel = "Send Back" + accessibilityTraits = .button + case .sending: + backgroundColor = Self.actionFill + label.isHidden = true + spinner.startAnimating() + isEnabled = false + accessibilityLabel = "Sending back" + accessibilityTraits = [.button, .notEnabled] + case .sentBack: + backgroundColor = .clear + label.isHidden = false + label.text = "Sent Back" + label.font = Self.footerFont + label.textColor = Self.footerColor + spinner.stopAnimating() + isEnabled = false + accessibilityLabel = "Sent Back" + accessibilityTraits = .staticText + } + alpha = 1 + } +} +#endif