From 7ea0e711e941e9f2b41924d3871e9dc487e3b9af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 14:56:11 -0500 Subject: [PATCH 01/10] feat(chat): persist a cash message's return offer and an activity's returned payment ConversationMessage carries CashReturn from CashContent.payment_return, stored in two new message columns, so a reopened DM knows a payment was sent back without asking the server. Activity carries returnedPayment in a side table beside requestedPayment. Schema 64 -> 65. --- .../FlipcashCore/Models/Activity.swift | 11 +++- .../Conversation/ConversationMessage.swift | 16 +++++- .../FlipcashStore/Database+Activities.swift | 46 ++++++++++++++-- .../Database+Conversations.swift | 13 ++++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 35 +++++++++++- .../ConversationModelMappingTests.swift | 27 ++++++++++ .../Database+ConversationsTests.swift | 29 ++++++++++ .../Database+ReturnedPaymentTests.swift | 54 +++++++++++++++++++ 9 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 FlipcashTests/Database/Database+ReturnedPaymentTests.swift 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/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/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) + } +} From 7404bb408fd10afb601158e6298af38d155a15f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:16:51 -0500 Subject: [PATCH 02/10] feat(chat): send back a DM's opening payment from its bubble The receiver of a cash message carrying a return offer gets a Send Back row under the bubble. A tap checks the balance at today's rate and opens a confirm sheet (amount, read-only currency, "Send back to ", "Swipe to Send Back"), or the existing insufficient-balance modal. The swipe pays the original's amount, currency and mint back to the sender's owner under the server-issued intent id. The status is asked once per DM open, by the receiver only, and only when no message in the transcript returns the offer. The intent id is held in memory for the open and never logged; a failed return reports as "Send back failed" and shows "Couldn't Send Back". Both people see "Sent Back" on the original once the return is in the transcript, and the return reads "You sent back" / " sent back". --- .../Conversation/ChatItem+Conversation.swift | 11 +- .../ChatScreenRepresentable.swift | 4 + .../ConversationLoadCoordinator.swift | 21 + .../Conversation/ConversationScreen.swift | 48 +++ .../SendBack/SendBackController.swift | 248 ++++++++++++ .../Conversation/SendBack/SendBackRow.swift | 67 ++++ .../SendBack/SendBackServices.swift | 107 +++++ .../Conversation/SendBack/SendBackSheet.swift | 74 ++++ Flipcash/Core/Session/Session.swift | 25 +- .../Models/Chat/ChatMessage.swift | 25 +- .../Chat/ChatCashCardCellTests.swift | 23 ++ FlipcashTests/Chat/SendBackTests.swift | 366 ++++++++++++++++++ .../FlipcashUI/Chat/ChatCashCardCell.swift | 34 +- .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 4 + .../FlipcashUI/Chat/SendBackRowView.swift | 107 +++++ 16 files changed, 1163 insertions(+), 7 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift create mode 100644 Flipcash/Core/Screens/Conversation/SendBack/SendBackRow.swift create mode 100644 Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift create mode 100644 Flipcash/Core/Screens/Conversation/SendBack/SendBackSheet.swift create mode 100644 FlipcashTests/Chat/SendBackTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift 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..ba311f2a9 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift @@ -0,0 +1,248 @@ +// +// 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)? + /// Whether the viewer's balance of `amount`'s mint covers it. + func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult + /// 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 + let token: String + let 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? { + guard local(for: offer.messageID) == .available else { return nil } + guard let priced = await services.priceReturn(of: offer.original) else { + presenter.showRateUnavailable() + return nil + } + let hasBalance = checkBalance(priced.amount) + analytics.tapped(hasBalance: hasBalance) + return hasBalance ? offer : nil + } + + // 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 + } + guard checkBalance(priced.amount) else { return .blocked } + states[offer.messageID] = .sending + submitTask = Task { await self.submit(offer, amount: priced.amount, pin: priced.pin) } + return .submitted + } + + /// Whether the balance covers `amount`; when it doesn't, shows the insufficient-balance modal. + private func checkBalance(_ amount: ExchangedFiat) -> Bool { + switch services.hasSufficientFunds(for: amount) { + case .sufficient: + return true + case .insufficient(let shortfall): + 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..d3564d88f --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift @@ -0,0 +1,107 @@ +// +// 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 hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult { + session.hasSufficientFunds(for: amount) + } + + 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. The `Send Back` context and `Send Back: Tapped` exist only in a +/// shared-core release that carries them, so this sends nothing until that release is pinned. +@MainActor +final class LiveSendBackAnalytics: SendBackAnalytics { + func tapped(hasBalance: Bool) {} + func sent(_ outcome: SendBackOutcome, amount: ExchangedFiat) {} +} 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/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/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/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index ece404cec..9b646d134 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -45,6 +45,29 @@ 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("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/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift new file mode 100644 index 000000000..be24e1f8d --- /dev/null +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -0,0 +1,366 @@ +// +// 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) + } + + func priceReturn(of original: ExchangedFiat) async -> (amount: ExchangedFiat, pin: VerifiedState)? { + (original, .stale(bonded: false)) + } + + func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult { + if let shortfall { return .insufficient(shortfall: shortfall) } + return .sufficient(amountToSend: amount) + } + + 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("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("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) + } +} + +// 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") + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index ea83a8642..3e8d38010 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -46,6 +46,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 +105,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 +129,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 +164,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 +206,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 +228,17 @@ 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) } + // 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..a79415bd0 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 @@ -822,6 +825,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..5f2da3f78 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -0,0 +1,107 @@ +// +// 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 row's height in every state (design node 13296:491). + static let height: CGFloat = 48 + + private static let actionFill = UIColor(Color.textMain) + private static let actionText = UIColor(Color.backgroundMain) + private static let dividerColor = UIColor.white.withAlphaComponent(0.08) + /// 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 = UIColor.white.withAlphaComponent(0.5) + + private let label = UILabel() + private let spinner = UIActivityIndicatorView(style: .medium) + private let divider = UIView() + /// 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 + divider.backgroundColor = Self.dividerColor + divider.isUserInteractionEnabled = false + divider.translatesAutoresizingMaskIntoConstraints = false + [label, spinner, divider].forEach(addSubview) + isAccessibilityElement = true + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: Self.height), + 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), + divider.topAnchor.constraint(equalTo: topAnchor), + divider.leadingAnchor.constraint(equalTo: leadingAnchor), + divider.trailingAnchor.constraint(equalTo: trailingAnchor), + divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + ]) + } + + @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 + switch state { + case .available: + backgroundColor = Self.actionFill + divider.isHidden = false + 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 + divider.isHidden = false + label.isHidden = true + spinner.startAnimating() + isEnabled = false + accessibilityLabel = "Sending back" + accessibilityTraits = [.button, .notEnabled] + case .sentBack: + backgroundColor = .clear + divider.isHidden = true + 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 From 3f3ebf203cebdd6362e706cb619372f33e651d76 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:20:19 -0500 Subject: [PATCH 03/10] feat(activity): title and open a sent-back payment's row by its DM A payment that returned a DM's opening payment reads "Sent back to " to the one who returned it and " sent back" to the one it returned to, falling back to the server's title until the counterparty resolves. Tapping it opens the DM while it exists, else the details screen as other rows do. --- .../Core/Screens/Main/Home/ActivityRow.swift | 35 ++++++++++- FlipcashTests/ActivityRowReturnTests.swift | 60 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 FlipcashTests/ActivityRowReturnTests.swift diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index f3bebdca1..221f49984 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -31,7 +31,10 @@ struct ActivityRow: View { var body: some View { Button { - router.push(.transactionDetails(activity)) + let dmExists = activity.returnedPayment.map { + sessionContainer.conversationController.conversation(withID: $0.conversationID) != nil + } ?? false + router.push(Self.destination(for: activity, dmExists: dmExists)) } label: { content } @@ -111,7 +114,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 +139,39 @@ 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 + /// while it still exists; every other row, and one whose DM is gone, opens its details. + static func destination(for activity: Activity, dmExists: Bool) -> AppRouter.Destination { + if let returned = activity.returnedPayment, dmExists { + 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/FlipcashTests/ActivityRowReturnTests.swift b/FlipcashTests/ActivityRowReturnTests.swift new file mode 100644 index 000000000..ab5dea9dd --- /dev/null +++ b/FlipcashTests/ActivityRowReturnTests.swift @@ -0,0 +1,60 @@ +// +// 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() { + #expect(title(.gave, name: "Alex Morgan") == "Sent back to Alex Morgan") + #expect(ActivityRow.destination(for: activity(.gave, returned: true), dmExists: true) == .tipConversation(dm)) + } + + @Test("17: the side it was returned to reads ' sent back' and opens the DM") + func receivedSide() { + #expect(title(.received, name: "Alex Morgan") == "Alex Morgan sent back") + #expect(ActivityRow.destination(for: activity(.received, returned: true), dmExists: 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("A return whose DM is gone, and any other row, opens the details screen") + func detailsFallback() { + let gone = activity(.gave, returned: true) + #expect(ActivityRow.destination(for: gone, dmExists: false) == .transactionDetails(gone)) + let plain = activity(.gave, returned: false) + #expect(ActivityRow.destination(for: plain, dmExists: true) == .transactionDetails(plain)) + } +} From 48db64f82536b68e6be9df2f1f14f60d69717832 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:44:28 -0500 Subject: [PATCH 04/10] fix(chat): draw the Sent Back footer in the card's caption color and drop the row divider The footer kept its own copy of the card's 50% white, and the Available row drew an 8% white divider over a white fill, where it can't be seen. Neither has an app token. The footer now reads ChatCashCardCell.secondaryColor, and the divider is gone. --- .../FlipcashUI/Chat/ChatCashCardCell.swift | 3 ++- .../FlipcashUI/Chat/SendBackRowView.swift | 16 ++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 3e8d38010..e5f80cf44 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. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift index 5f2da3f78..cff469058 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -20,14 +20,12 @@ final class SendBackRowView: UIControl { private static let actionFill = UIColor(Color.textMain) private static let actionText = UIColor(Color.backgroundMain) - private static let dividerColor = UIColor.white.withAlphaComponent(0.08) /// 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 = UIColor.white.withAlphaComponent(0.5) + private static let footerColor = ChatCashCardCell.secondaryColor private let label = UILabel() private let spinner = UIActivityIndicatorView(style: .medium) - private let divider = UIView() /// The state last applied; read by tests. private(set) var displayedState: ChatCashContent.SendBack = .available @@ -38,10 +36,7 @@ final class SendBackRowView: UIControl { spinner.color = Self.actionText spinner.hidesWhenStopped = true spinner.translatesAutoresizingMaskIntoConstraints = false - divider.backgroundColor = Self.dividerColor - divider.isUserInteractionEnabled = false - divider.translatesAutoresizingMaskIntoConstraints = false - [label, spinner, divider].forEach(addSubview) + [label, spinner].forEach(addSubview) isAccessibilityElement = true NSLayoutConstraint.activate([ @@ -52,10 +47,6 @@ final class SendBackRowView: UIControl { label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), spinner.centerXAnchor.constraint(equalTo: centerXAnchor), spinner.centerYAnchor.constraint(equalTo: centerYAnchor), - divider.topAnchor.constraint(equalTo: topAnchor), - divider.leadingAnchor.constraint(equalTo: leadingAnchor), - divider.trailingAnchor.constraint(equalTo: trailingAnchor), - divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), ]) } @@ -72,7 +63,6 @@ final class SendBackRowView: UIControl { switch state { case .available: backgroundColor = Self.actionFill - divider.isHidden = false label.isHidden = false label.text = "Send Back" label.font = .appTextSmall @@ -83,7 +73,6 @@ final class SendBackRowView: UIControl { accessibilityTraits = .button case .sending: backgroundColor = Self.actionFill - divider.isHidden = false label.isHidden = true spinner.startAnimating() isEnabled = false @@ -91,7 +80,6 @@ final class SendBackRowView: UIControl { accessibilityTraits = [.button, .notEnabled] case .sentBack: backgroundColor = .clear - divider.isHidden = true label.isHidden = false label.text = "Sent Back" label.font = Self.footerFont From b9bdb7a456db181f2a0a6f51498846c7910d2641 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:06:46 -0500 Subject: [PATCH 05/10] fix(chat): Send Back pays only what the balance holds, once per tap The balance check went through Session.hasSufficientFunds, which answers sufficient for a shortfall under half a denomination because its callers then send its amountToSend. Send Back pays the full priced amount, so a balance a fraction of a cent short passed the check and failed on chain. Compare held quarks with needed quarks instead, on tap and on swipe; the session check now only names the shortfall for the modal. A second tap while the first was pricing reported Send Back: Tapped twice and could open the sheet twice. Taps now wait for the one in flight. The sheet took the token's name from the viewer's balance, falling back to "Cash" with no icon when there was none. It now takes both from the mint's metadata. --- .../SendBack/SendBackController.swift | 45 +++++++--- .../SendBack/SendBackServices.swift | 9 ++ FlipcashTests/Chat/SendBackTests.swift | 85 ++++++++++++++++++- 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift index ba311f2a9..0aca3fc8d 100644 --- a/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift @@ -16,8 +16,12 @@ protocol SendBackServicing: AnyObject { /// `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)? - /// Whether the viewer's balance of `amount`'s mint covers it. + /// The quarks of `mint` the viewer holds; 0 with no balance. + 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 @@ -62,8 +66,8 @@ struct SendBackOffer: Identifiable, Equatable { let original: ExchangedFiat /// The DM peer's display name, as the chat title shows it. let recipientName: String - let token: String - let iconURL: URL? + var token: String + var iconURL: URL? var id: MessageID { messageID } } @@ -144,16 +148,27 @@ final class SendBackController { /// 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? { - guard local(for: offer.messageID) == .available else { return nil } + // 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 } let hasBalance = checkBalance(priced.amount) analytics.tapped(hasBalance: hasBalance) - return hasBalance ? offer : nil + 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 { @@ -177,15 +192,23 @@ final class SendBackController { return .submitted } - /// Whether the balance covers `amount`; when it doesn't, shows the insufficient-balance modal. + /// Whether the held quarks cover `amount`'s quarks; when they don't, shows the + /// insufficient-balance modal. + /// + /// 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 { - switch services.hasSufficientFunds(for: amount) { - case .sufficient: + let needed = amount.onChainAmount.quarks + if needed > 0, services.heldQuarks(of: amount.mint) >= needed { return true - case .insufficient(let shortfall): - presenter.showInsufficientBalance(shortfall: shortfall, mint: amount.mint) - return false } + 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 { diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift index d3564d88f..3a601e3b6 100644 --- a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift @@ -34,10 +34,19 @@ final class LiveSendBackServices: SendBackServicing { return (amount, pin) } + func heldQuarks(of mint: PublicKey) -> UInt64 { + session.balance(for: mint)?.quarks ?? 0 + } + 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) } diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index be24e1f8d..6af0d6cdd 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -168,15 +168,35 @@ private final class FakeServices: SendBackServicing { 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 + private var priceRelease: CheckedContinuation? + + func releasePrice() { priceRelease?.resume(); priceRelease = nil } + func priceReturn(of original: ExchangedFiat) async -> (amount: ExchangedFiat, pin: VerifiedState)? { - (original, .stale(bonded: false)) + priceCalls += 1 + if holdPrice { await withCheckedContinuation { priceRelease = $0 } } + return (original, .stale(bonded: false)) + } + + func heldQuarks(of mint: PublicKey) -> UInt64 { + 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 { @@ -275,6 +295,56 @@ struct SendBackControllerTests { #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() @@ -348,6 +418,19 @@ struct SendBackControllerTests { #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 - From bf0017706268935fd9c2225298d1afd3a7574a5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 16:06:46 -0500 Subject: [PATCH 06/10] fix(activity): fetch a sent-back row's DM before falling back to details Tapping a sent-back row looked the DM up in local storage only, so a DM not stored yet opened the details screen. It now goes through hydratedConversation, which fetches the chat from the server when it isn't stored, and opens details only when the server doesn't return it. --- .../Core/Screens/Main/Home/ActivityRow.swift | 25 ++++++++++++----- FlipcashTests/ActivityRowReturnTests.swift | 27 +++++++++++++------ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index 221f49984..902a9539a 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -31,10 +31,17 @@ struct ActivityRow: View { var body: some View { Button { - let dmExists = activity.returnedPayment.map { - sessionContainer.conversationController.conversation(withID: $0.conversationID) != nil - } ?? false - router.push(Self.destination(for: activity, dmExists: dmExists)) + 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 } @@ -152,9 +159,13 @@ struct ActivityRow: View { } /// Where a tap on the row goes: a payment that sent back a DM's opening payment opens that DM - /// while it still exists; every other row, and one whose DM is gone, opens its details. - static func destination(for activity: Activity, dmExists: Bool) -> AppRouter.Destination { - if let returned = activity.returnedPayment, dmExists { + /// 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) diff --git a/FlipcashTests/ActivityRowReturnTests.swift b/FlipcashTests/ActivityRowReturnTests.swift index ab5dea9dd..58a41d631 100644 --- a/FlipcashTests/ActivityRowReturnTests.swift +++ b/FlipcashTests/ActivityRowReturnTests.swift @@ -33,15 +33,15 @@ struct ActivityRowReturnTests { } @Test("16: the side that sent it back reads 'Sent back to ' and opens the DM") - func sentSide() { + func sentSide() async { #expect(title(.gave, name: "Alex Morgan") == "Sent back to Alex Morgan") - #expect(ActivityRow.destination(for: activity(.gave, returned: true), dmExists: true) == .tipConversation(dm)) + #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() { + func receivedSide() async { #expect(title(.received, name: "Alex Morgan") == "Alex Morgan sent back") - #expect(ActivityRow.destination(for: activity(.received, returned: true), dmExists: true) == .tipConversation(dm)) + #expect(await ActivityRow.destination(for: activity(.received, returned: true)) { _ in true } == .tipConversation(dm)) } @Test("An unresolved name falls back to the server's title") @@ -50,11 +50,22 @@ struct ActivityRowReturnTests { #expect(title(.received, name: "") == nil) } - @Test("A return whose DM is gone, and any other row, opens the details screen") - func detailsFallback() { + @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(ActivityRow.destination(for: gone, dmExists: false) == .transactionDetails(gone)) + #expect(await ActivityRow.destination(for: gone) { _ in false } == .transactionDetails(gone)) let plain = activity(.gave, returned: false) - #expect(ActivityRow.destination(for: plain, dmExists: true) == .transactionDetails(plain)) + #expect(await ActivityRow.destination(for: plain) { _ in true } == .transactionDetails(plain)) } } From af4b306a957c24ed000b7f0766c5cbdcf8818cfe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 18:48:54 -0500 Subject: [PATCH 07/10] fix(chat): size the Send Back row 44 and the footer 36, and show either without animating The Sent Back footer takes the place of the card's bottom padding. A push that changes a card's Send Back state applies that card first, in place with animation off, so the bubble doesn't slide; the rest of the push keeps its spring. --- .../Chat/ChatCashCardCellTests.swift | 9 +++ .../Chat/ChatSendBackStageTests.swift | 55 +++++++++++++++++++ .../FlipcashUI/Chat/ChatCashCardCell.swift | 2 + .../FlipcashUI/Chat/ChatViewController.swift | 39 +++++++++++++ .../FlipcashUI/Chat/SendBackRowView.swift | 15 ++++- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 FlipcashTests/Chat/ChatSendBackStageTests.swift diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index 9b646d134..65ee64135 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -61,6 +61,15 @@ struct ChatCashCardCellTests { } } + @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) 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/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index e5f80cf44..5252844e6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -234,6 +234,8 @@ public final class ChatCashCardCell: ChatColumnCell { 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index a79415bd0..4008f95de 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -537,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 @@ -677,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 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift index cff469058..c9727a462 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -15,8 +15,14 @@ import FlipcashCore /// card's shape mask rounds its lower corners. final class SendBackRowView: UIControl { - /// The row's height in every state (design node 13296:491). - static let height: CGFloat = 48 + /// 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) @@ -26,6 +32,7 @@ final class SendBackRowView: UIControl { 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 @@ -39,8 +46,9 @@ final class SendBackRowView: UIControl { [label, spinner].forEach(addSubview) isAccessibilityElement = true + heightConstraint = heightAnchor.constraint(equalToConstant: Self.actionHeight) NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: Self.height), + heightConstraint, label.centerXAnchor.constraint(equalTo: centerXAnchor), label.centerYAnchor.constraint(equalTo: centerYAnchor), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), @@ -60,6 +68,7 @@ final class SendBackRowView: UIControl { func apply(_ state: ChatCashContent.SendBack) { displayedState = state + heightConstraint.constant = Self.height(for: state) switch state { case .available: backgroundColor = Self.actionFill From e8f6033d61a52da82bc1995f8b07238d2d1759c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 18:48:54 -0500 Subject: [PATCH 08/10] fix(chat): count a Send Back tap only when its balance can be determined With no fresh rate, or no loaded balance of the payment's mint, the tap and the swipe show Rate Unavailable and Send Back: Tapped doesn't fire, as on Android. --- .../SendBack/SendBackController.swift | 26 +++++++++---- .../SendBack/SendBackServices.swift | 4 +- FlipcashTests/Chat/SendBackTests.swift | 39 ++++++++++++++++++- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift index 0aca3fc8d..2e1a3bc40 100644 --- a/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackController.swift @@ -16,8 +16,8 @@ protocol SendBackServicing: AnyObject { /// `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; 0 with no balance. - func heldQuarks(of mint: PublicKey) -> UInt64 + /// 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. @@ -156,7 +156,12 @@ final class SendBackController { presenter.showRateUnavailable() return nil } - let hasBalance = checkBalance(priced.amount) + // 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 @@ -186,21 +191,28 @@ final class SendBackController { presenter.showRateUnavailable() return .blocked } - guard checkBalance(priced.amount) else { 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. + /// 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 { + 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, services.heldQuarks(of: amount.mint) >= needed { + if needed > 0, held >= needed { return true } var shortfall: ExchangedFiat? diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift index 3a601e3b6..1b95e76fe 100644 --- a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift @@ -34,8 +34,8 @@ final class LiveSendBackServices: SendBackServicing { return (amount, pin) } - func heldQuarks(of mint: PublicKey) -> UInt64 { - session.balance(for: mint)?.quarks ?? 0 + func heldQuarks(of mint: PublicKey) -> UInt64? { + session.balance(for: mint)?.quarks } func hasSufficientFunds(for amount: ExchangedFiat) -> Session.SufficientFundsResult { diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index 6af0d6cdd..25afcfda5 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -175,6 +175,10 @@ private final class FakeServices: SendBackServicing { 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 } @@ -182,11 +186,13 @@ private final class FakeServices: SendBackServicing { 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 { - held ?? (shortfall == nil ? 10_000_000 : 0) + 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 { @@ -407,6 +413,35 @@ struct SendBackControllerTests { #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() From 664fe18bacfd033ecf6563d1e740d9d8d447ad03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 13:07:23 -0600 Subject: [PATCH 09/10] chore(deps): bump flipcash-shared-core-spm to 0.20.0 --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- CrossPlatformVectors/Package.swift | 2 +- FlipcashCore/Package.swift | 2 +- FlipcashUI/Package.swift | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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/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( From b2dbc8be37bb266ec0db41440d5b5c97ff2306bd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 3 Nov 2026 15:28:31 -0500 Subject: [PATCH 10/10] feat(analytics): send Send Back: Tapped and Sent Cash with the Send Back context Needs a flipcash-shared-core-spm release carrying the catalogue change (code-android-app feat/analytics-send-back): until the pin moves, this commit does not compile against the published package. Sent Cash fires once per swipe; a return the server already counts as sent reports Failure with Error "Already Returned". No property carries the intent, chat or message id. --- .../SendBack/SendBackServices.swift | 19 ++++++-- Flipcash/Utilities/Events.swift | 22 +++++++++ FlipcashTests/Chat/SendBackTests.swift | 47 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift index 1b95e76fe..4c357f78f 100644 --- a/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift +++ b/Flipcash/Core/Screens/Conversation/SendBack/SendBackServices.swift @@ -107,10 +107,21 @@ final class LiveSendBackPresenter: SendBackPresenting { } } -/// Send Back's analytics. The `Send Back` context and `Send Back: Tapped` exist only in a -/// shared-core release that carries them, so this sends nothing until that release is pinned. +/// Send Back's analytics: `Send Back: Tapped`, and `Sent Cash` with the `Send Back` context. @MainActor final class LiveSendBackAnalytics: SendBackAnalytics { - func tapped(hasBalance: Bool) {} - func sent(_ outcome: SendBackOutcome, amount: ExchangedFiat) {} + 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/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/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index 25afcfda5..2eb760537 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -482,3 +482,50 @@ struct SendBackPrivacyTests { #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) }) + } +}