From a26112734b448d2be37223c955d95323eb67911e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 15:21:50 -0500 Subject: [PATCH] feat(chat): show the received bill when cash arrives in the open chat A cash message on the live stream that pays me, in the chat on screen with nothing over it, now presents the received bill (no deposit toast, Put in Wallet stays in the chat). The keyboard is lowered for the bill and raised again when it closes, unless the app was backgrounded. The decision is a pure function in FlipcashCore (ChatCashBill). A second payment is dropped while one bill is preparing or showing. The chat's receive haptic is skipped for the billed message. Behind the Chat Cash Bill beta flag, public and on by default. --- Flipcash/Core/Controllers/BetaFlags.swift | 7 + .../Controllers/ChatCashBillController.swift | 175 ++++++++++++ .../Controllers/ConversationController.swift | 4 + .../ChatScreenRepresentable.swift | 3 + .../Conversation/ConversationScreen.swift | 13 +- Flipcash/Core/Session/Session.swift | 17 +- .../Core/Session/SessionAuthenticator.swift | 28 ++ .../Models/Conversation/ChatCashBill.swift | 98 +++++++ .../FlipcashCoreTests/ChatCashBillTests.swift | 180 ++++++++++++ FlipcashTests/BetaFlagsTests.swift | 16 ++ .../Chat/ChatCashBillControllerTests.swift | 256 ++++++++++++++++++ .../Chat/ChatScreenViewController.swift | 5 + 12 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 Flipcash/Core/Controllers/ChatCashBillController.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatCashBill.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatCashBillTests.swift create mode 100644 FlipcashTests/Chat/ChatCashBillControllerTests.swift diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 425256fc2..3ae10d48e 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -180,6 +180,7 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase + case chatCashBill var id: String { localizedTitle @@ -191,6 +192,8 @@ extension BetaFlags { return "Vibrate on scan" case .enableCoinbase: return "Enable Coinbase" + case .chatCashBill: + return "Chat Cash Bill" } } @@ -200,6 +203,8 @@ extension BetaFlags { return "If enabled, the device will vibrate to indicate that the camera has registered the code on the bill" case .enableCoinbase: return "If enabled, Coinbase onramp will be available regardless of region" + case .chatCashBill: + return "Show the bill when someone sends you cash in the chat you have open." } } @@ -208,6 +213,7 @@ extension BetaFlags { switch self { case .vibrateOnScan: return .developer case .enableCoinbase: return .developer + case .chatCashBill: return .publicBeta } } @@ -217,6 +223,7 @@ extension BetaFlags { switch self { case .vibrateOnScan: return false case .enableCoinbase: return false + case .chatCashBill: return true } } } diff --git a/Flipcash/Core/Controllers/ChatCashBillController.swift b/Flipcash/Core/Controllers/ChatCashBillController.swift new file mode 100644 index 000000000..e67ce601d --- /dev/null +++ b/Flipcash/Core/Controllers/ChatCashBillController.swift @@ -0,0 +1,175 @@ +// +// ChatCashBillController.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +#if DEBUG +import notify +#endif +import FlipcashCore +import FlipcashUI + +nonisolated private let logger = Logger(label: "flipcash.chat-cash-bill") + +/// The open chat's keyboard, which a bill lowers and gives back. +@MainActor +protocol ChatComposerKeyboard: AnyObject { + var isUp: Bool { get } + func lower() + func raise() +} + +/// The open chat screen's keyboard, reached through its view controller. +@MainActor +final class ChatKeyboardHandle: ChatComposerKeyboard { + weak var screen: ChatScreenViewController? + + var isUp: Bool { screen?.composerHoldsKeyboard ?? false } + func lower() { screen?.dismissKeyboard() } + func raise() { screen?.focusComposer() } +} + +/// Shows the received bill over the open chat when cash to the user lands on the live stream. +/// +/// The decision is `ChatCashBill.billAmount(for:in:)`. This owns what surrounds it: waiting for +/// the verified state the bill's give operation needs, keeping a second payment out while the first +/// is being prepared or shown, and handing the keyboard back when the bill closes. +@MainActor +final class ChatCashBillController { + + /// Where a chat bill is in its life. Only one exists at a time. + private enum Phase: Equatable { + case idle + /// Waiting on verified state for `messageID`; the bill is not up yet. + case preparing(messageID: MessageID) + /// The bill is up; `restoreKeyboard` is whether the composer held the keyboard when it came. + case showing(restoreKeyboard: Bool) + } + + private let selfUserID: UserID + + var isEnabled: () -> Bool = { BetaFlags.shared.hasEnabled(.chatCashBill) } + /// The conversation on screen in the foreground. + var openConversationID: () -> ConversationID? = { nil } + /// A sheet, media viewer, dialog or other bill is over the chat, or the chat is in a sheet. + var somethingOnTop: () -> Bool = { false } + var isBillShowing: () -> Bool = { false } + var isForeground: () -> Bool = { UIApplication.shared.applicationState == .active } + /// Subscribes the mint and waits for the verified state the bill's give operation needs. + var verifiedState: (ExchangedFiat) async -> VerifiedState? = { _ in nil } + var present: (Session.BillDescription) -> Void = { _ in } + + /// Set by the open chat screen while it is on screen. + var keyboard: (any ChatComposerKeyboard)? + + private var phase: Phase = .idle + /// The message that earned the bill, recorded synchronously so the chat's receive haptic, which + /// fires on the same transcript update, can skip it. + private(set) var billedMessageID: MessageID? + + init(selfUserID: UserID) { + self.selfUserID = selfUserID + #if DEBUG + registerDebugTrigger() + #endif + } + + /// Whether `messageID` is showing, or about to show, the bill, so the chat skips its own buzz. + func isBilling(_ messageID: MessageID) -> Bool { + billedMessageID == messageID + } + + /// Considers a message that reached the client as a live stream event. Catch-up never comes here. + func handleLive(_ messages: [ConversationMessage], in conversationID: ConversationID, countedThrough: MessageID?) { + for message in messages { + let context = ChatCashBill.Context( + source: .live, + selfUserID: selfUserID, + messageConversationID: conversationID, + openConversationID: openConversationID(), + countedThrough: countedThrough, + flagEnabled: isEnabled(), + somethingOnTop: somethingOnTop() || !isForeground(), + billBusy: phase != .idle || isBillShowing() + ) + guard let amount = ChatCashBill.billAmount(for: message, in: context) else { continue } + billedMessageID = message.id + phase = .preparing(messageID: message.id) + Task { await prepareAndShow(amount, messageID: message.id, in: conversationID) } + // First one wins: the rest of the batch is dropped. + return + } + } + + private func prepareAndShow(_ amount: ExchangedFiat, messageID: MessageID, in conversationID: ConversationID) async { + let state = await verifiedState(amount) + + // Things may have moved during the wait: re-check what the decision checked. + guard phase == .preparing(messageID: messageID) else { return } + guard let state, + openConversationID() == conversationID, + !somethingOnTop(), isForeground(), !isBillShowing() + else { + logger.info("Chat cash bill dropped after preparing", metadata: [ + "hasVerifiedState": "\(state != nil)", + ]) + abandon(messageID) + return + } + + let keyboardWasUp = keyboard?.isUp ?? false + if keyboardWasUp { keyboard?.lower() } + phase = .showing(restoreKeyboard: keyboardWasUp) + present(.init(exchangedFiat: amount, received: true, verifiedState: state, announcesDeposit: false)) + } + + private func abandon(_ messageID: MessageID) { + phase = .idle + if billedMessageID == messageID { billedMessageID = nil } + } + + /// Called after any bill closes. Gives the keyboard back only if this controller's bill took it + /// and the app is still in front: a bill dismissed by backgrounding must not raise it. + func billDidClose() { + guard case .showing(let restoreKeyboard) = phase else { return } + phase = .idle + if restoreKeyboard, isForeground() { + keyboard?.raise() + } + } + + #if DEBUG + // MARK: - Debug trigger - + + /// Feeds a synthetic $1.00 payment to me through the live decision, for recording the bill and + /// the keyboard without a second account. Never stored. Fire with + /// `xcrun simctl spawn notifyutil -p flipcash.debug.chatCashBill`. + private var debugToken: Int32 = 0 + + private func registerDebugTrigger() { + notify_register_dispatch("flipcash.debug.chatCashBill", &debugToken, .main) { [weak self] _ in + MainActor.assumeIsolated { self?.fireDebugPayment() } + } + } + + private func fireDebugPayment() { + guard let conversationID = openConversationID() else { + logger.info("Debug chat cash bill: no chat open") + return + } + let message = ConversationMessage( + id: MessageID(value: UInt64(Date().timeIntervalSince1970)), + senderID: UUID(), + content: .cash(ExchangedFiat(nativeAmount: .usd(1), rate: .oneToOne)), + cashAction: .sent, + cashReceiverID: selfUserID, + date: .now, + unreadSeq: 0 + ) + handleLive([message], in: conversationID, countedThrough: nil) + } + #endif +} diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b1eec1d0f..373c3e86e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -442,6 +442,9 @@ final class ConversationController { /// Raises the in-app pill for reactions on the user's own messages; wired at session setup. @ObservationIgnored var reactionAlerts: ReactionAlertController? + /// Shows the received bill when cash to the user lands live in the open chat; wired at session setup. + @ObservationIgnored var chatCashBills: ChatCashBillController? + var store: ConversationStore /// The current blocklist (wired to `BlocklistController`), used to reconcile @@ -1715,6 +1718,7 @@ final class ConversationController { if outcome.succeeded { commitReconciled(pairs, in: conversationID) receipts.countReceived(reconciled, countedThrough: countedThrough, delivery: .live) + chatCashBills?.handleLive(reconciled, in: conversationID, countedThrough: countedThrough) } else { store.reseatCursor((try? database.catchupCursor(conversationID: conversationID)) ?? 0, for: conversationID) scheduleGapCatchUp(conversationID) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 738a69398..0f957162f 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -159,6 +159,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// no touches, opened at the newest message. Read reporting is the caller's to turn off, through /// `reportsReads` and `onMessagesSeen`, as it is for the screen. var isReadOnlyPreview = false + /// Lets a received bill lower and restore this screen's keyboard. + var keyboardHandle: ChatKeyboardHandle? func makeUIViewController(context: Context) -> ChatScreenViewController { if isReadOnlyPreview { return makePreviewController(context: context) } @@ -179,6 +181,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { jumpHost.view.clipsToBounds = false jumpHost.safeAreaRegions = [] let screen = ChatScreenViewController(bar: barHost.view, barController: barHost, jump: jump, jumpButtonController: jumpHost) + keyboardHandle?.screen = screen screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2ae3d86ea..9b6bfe601 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -59,6 +59,8 @@ struct ConversationScreen: View { @Environment(\.scenePhase) private var scenePhase @State private var didInitialRead = false + /// The chat's keyboard, handed to the received-bill flow while this screen is up. + @State private var keyboardHandle = ChatKeyboardHandle() @State private var didOpenRequest = false /// The chat whose opening load has landed, for a coordinator built after it. @State private var loadedConversationID: ConversationID? @@ -550,7 +552,8 @@ struct ConversationScreen: View { mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer, transcriptTopInset: Self.transcriptTopInset(slots: [anchorHeight, stackHeight, showsPinBar ? pinBarHeight : 0]), - messageJump: messageJump + messageJump: messageJump, + keyboardHandle: keyboardHandle ) } @@ -1343,12 +1346,15 @@ struct ConversationScreen: View { guard old != nil, didInitialRead, let last = latestConfirmedMessage, last.buzzesOnReceive(selfUserID: conversationController.selfUserID), - conversationController.visibleConversationID == conversationID + conversationController.visibleConversationID == conversationID, + // One buzz: a message that raises the received bill is felt through the bill. + conversationController.chatCashBills?.isBilling(last.id) != true else { return false } return true } .onAppear { setVisibleConversation(conversationID, source: "onAppear") + conversationController.chatCashBills?.keyboard = keyboardHandle syncCoordinator(conversationID) composer.linkPreview.load = ComposerLinkPreview.loader( metadata: sessionContainer.linkMetadataSource, @@ -1383,6 +1389,9 @@ struct ConversationScreen: View { } } .onDisappear { + if conversationController.chatCashBills?.keyboard === keyboardHandle { + conversationController.chatCashBills?.keyboard = nil + } saveDraft(flushing: true) coordinator?.pendingRead.commit() if let conversationID { diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 0f89464a9..5d2916362 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -115,6 +115,10 @@ class Session { billState.bill != nil } + /// Called after any bill closes (Put in Wallet, swipe, scrim, backgrounding, a grab). Lets the + /// open chat hand the keyboard back. + @ObservationIgnored var onCashBillDismissed: (() -> Void)? + /// Whether a `ScanCashOperation` is currently in flight. Used by /// `ScanViewModel` to prevent new codes from being registered while /// a grab is being processed, avoiding orphaned entries in the @@ -1405,8 +1409,9 @@ class Session { } func showCashBill(_ billDescription: BillDescription) { - // Only inbound bills enqueue a "+$" deposit toast; sent bills don't. - if billDescription.received { + // Only inbound bills enqueue a "+$" deposit toast; sent bills don't. A chat bill opts out: + // the payment is already a bubble in the open chat. + if billDescription.received && billDescription.announcesDeposit { enqueue(toast: .init( amount: billDescription.exchangedFiat.nativeAmount, isDeposit: true @@ -1657,6 +1662,8 @@ class Session { // Consume toast after bill state is cleared // so isShowingBill returns false consumeToast() + + onCashBillDismissed?() } /// Records the wallet's current figures against an incoming deposit in `mint`. @@ -2080,11 +2087,15 @@ extension Session { let exchangedFiat: ExchangedFiat let received: Bool let verifiedState: VerifiedState? + /// Whether a received bill enqueues the "+$X" deposit toast. Off for a payment that arrived + /// in the open chat, where the cash bubble already says so. + let announcesDeposit: Bool - init(exchangedFiat: ExchangedFiat, received: Bool, verifiedState: VerifiedState? = nil) { + init(exchangedFiat: ExchangedFiat, received: Bool, verifiedState: VerifiedState? = nil, announcesDeposit: Bool = true) { self.exchangedFiat = exchangedFiat self.received = received self.verifiedState = verifiedState + self.announcesDeposit = announcesDeposit } } } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 50d412f46..58b560833 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -357,9 +357,37 @@ final class SessionAuthenticator { flipClient: container.flipClient ) wireReactionAlerts(on: sessionContainer, toasts: container.toasts) + wireChatCashBills(on: sessionContainer) return sessionContainer } + /// Points the received-bill-in-chat flow at this session's bill, router and rates. + private func wireChatCashBills(on sessionContainer: SessionContainer) { + let session = sessionContainer.session + let conversations = sessionContainer.conversationController + let router = sessionContainer.appRouter + let ratesController = sessionContainer.ratesController + let bills = ChatCashBillController(selfUserID: session.userID) + bills.openConversationID = { [weak conversations] in conversations?.visibleConversationID } + bills.somethingOnTop = { [weak session, weak router] in + // A dialog draws in its own window. Any view controller presented from the root is a + // sheet, the media viewer, or this chat's own sheet; the router's list catches a sheet + // still on its way up. + session?.dialogItem != nil + || !(router?.presentedSheets.isEmpty ?? true) + || UIApplication.shared.currentKeyWindow?.rootViewController?.presentedViewController != nil + } + bills.isBillShowing = { [weak session] in session?.isShowingBill ?? false } + bills.verifiedState = { [weak ratesController] amount in + guard let ratesController else { return nil } + ratesController.ensureMintSubscribed(amount.mint) + return await ratesController.awaitVerifiedState(for: amount.nativeAmount.currency, mint: amount.mint) + } + bills.present = { [weak session] description in session?.showCashBill(description) } + session.onCashBillDismissed = { [weak bills] in bills?.billDidClose() } + conversations.chatCashBills = bills + } + /// Points the in-app reaction pill at this session's chats, rates and router. The pill draws /// through the app's toast host; its tap opens the chat at the reacted message. private func wireReactionAlerts(on sessionContainer: SessionContainer, toasts: ToastController) { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatCashBill.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatCashBill.swift new file mode 100644 index 000000000..c0fac8e6c --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatCashBill.swift @@ -0,0 +1,98 @@ +// +// ChatCashBill.swift +// FlipcashCore +// + +import Foundation + +extension ConversationMessage { + + /// What `selfUserID` was paid by this `.cash` message, in the currency and mint the message + /// carries; `nil` for non-cash content, the user's own sends, and a group send that does not + /// list them. + /// + /// Same rule as Android's `receivedBy`: a group send pays only the members it lists, each their + /// own part (never the total); a single-receiver send pays that receiver; a message with no + /// receiver on record is a DM payment, which pays the one other person in full. + public func cashReceived(by selfUserID: UserID) -> ExchangedFiat? { + guard case .cash(let total) = content, !isFromSelf(selfUserID) else { return nil } + + if !cashPayments.isEmpty { + guard let part = cashPayments.first(where: { $0.receiverID == selfUserID }), + let nativeValue = Decimal(string: "\(part.nativeAmount)") + else { return nil } + return ExchangedFiat( + onChainAmount: TokenAmount(quarks: part.quarks, mint: total.mint), + nativeAmount: FiatAmount(value: nativeValue, currency: total.nativeAmount.currency), + currencyRate: total.currencyRate + ) + } + if let cashReceiverID { + return cashReceiverID == selfUserID ? total : nil + } + return total + } +} + +/// Whether a payment that arrived in a chat earns the received bill over it. +public enum ChatCashBill { + + /// How the message reached the client. Only a live stream event can show a bill; a catch-up + /// batch, delta, history page or initial load is replayed history. + public enum Source: Equatable, Sendable { + case live + case catchUp + } + + /// The decision's inputs, gathered by the caller at the moment the message lands. + public struct Context: Sendable { + public var source: Source + public var selfUserID: UserID + /// The conversation the message belongs to. + public var messageConversationID: ConversationID + /// The conversation on screen in the foreground, if any. + public var openConversationID: ConversationID? + /// The newest message id the store held before this batch landed. A message at or below + /// it is a re-delivery of one already seen. + public var countedThrough: MessageID? + public var flagEnabled: Bool + /// A sheet, media viewer, dialog or another bill is over the chat, or the chat itself sits + /// in a sheet. + public var somethingOnTop: Bool + /// A chat bill is showing or still being prepared. + public var billBusy: Bool + + public init( + source: Source, + selfUserID: UserID, + messageConversationID: ConversationID, + openConversationID: ConversationID?, + countedThrough: MessageID?, + flagEnabled: Bool, + somethingOnTop: Bool, + billBusy: Bool + ) { + self.source = source + self.selfUserID = selfUserID + self.messageConversationID = messageConversationID + self.openConversationID = openConversationID + self.countedThrough = countedThrough + self.flagEnabled = flagEnabled + self.somethingOnTop = somethingOnTop + self.billBusy = billBusy + } + } + + /// The amount to show on the bill (the user's share), or `nil` when no bill should show. A + /// dropped payment is not queued: its bubble is in the transcript and that is all it gets. + public static func billAmount(for message: ConversationMessage, in context: Context) -> ExchangedFiat? { + guard context.flagEnabled, + context.source == .live, + context.openConversationID == context.messageConversationID, + !context.somethingOnTop, + !context.billBusy + else { return nil } + if let countedThrough = context.countedThrough, message.id <= countedThrough { return nil } + return message.cashReceived(by: context.selfUserID) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatCashBillTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatCashBillTests.swift new file mode 100644 index 000000000..5dcb1a366 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatCashBillTests.swift @@ -0,0 +1,180 @@ +// +// ChatCashBillTests.swift +// FlipcashCoreTests +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Chat cash bill trigger") +struct ChatCashBillTests { + + private let me = UUID() + private let them = UUID() + private let third = UUID() + private let chatA = ConversationID(data: Data(repeating: 0xA1, count: 32)) + private let chatB = ConversationID(data: Data(repeating: 0xB2, count: 32)) + + private func cash( + from sender: UUID? = nil, + id: UInt64 = 10, + amount: FiatAmount = .usd(20), + receiver: UUID? = nil, + payments: [CashPayment] = [], + action: CashAction = .sent, + requestID: PaymentRequestID? = nil, + cashReturn: CashReturn? = nil + ) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), senderID: sender ?? them, + content: .cash(ExchangedFiat(nativeAmount: amount, rate: .oneToOne)), + cashAction: action, cashReceiverID: receiver, cashPayments: payments, + paymentRequestID: requestID, cashReturn: cashReturn, + date: Date(timeIntervalSince1970: 2_000_000), unreadSeq: 1 + ) + } + + private func context( + source: ChatCashBill.Source = .live, + in conversation: ConversationID? = nil, + open: ConversationID?? = nil, + countedThrough: MessageID? = MessageID(value: 5), + flag: Bool = true, + onTop: Bool = false, + busy: Bool = false + ) -> ChatCashBill.Context { + ChatCashBill.Context( + source: source, selfUserID: me, + messageConversationID: conversation ?? chatA, + openConversationID: open ?? chatA, + countedThrough: countedThrough, + flagEnabled: flag, somethingOnTop: onTop, billBusy: busy + ) + } + + private func amount(_ message: ConversationMessage, _ context: ChatCashBill.Context) -> Decimal? { + ChatCashBill.billAmount(for: message, in: context)?.nativeAmount.value + } + + // 1 + @Test("Live cash from someone else to me shows a bill of the message amount") + func liveCashToMe() { + #expect(amount(cash(receiver: me), context()) == 20) + } + + // 2 + @Test("My own send never shows a bill") + func ownSend() { + #expect(amount(cash(from: me, receiver: them), context()) == nil) + } + + // 3 + @Test("A group send shows my share, not the total") + func groupShare() throws { + let payments = [ + CashPayment(receiverID: third, nativeAmount: 5, quarks: 5_000_000), + CashPayment(receiverID: me, nativeAmount: 7.5, quarks: 7_500_000), + CashPayment(receiverID: UUID(), nativeAmount: 7.5, quarks: 7_500_000), + ] + let message = cash(amount: .usd(20), payments: payments) + let share = try #require(ChatCashBill.billAmount(for: message, in: context())) + #expect(share.nativeAmount.value == Decimal(string: "7.5")) + #expect(share.onChainAmount.quarks == 7_500_000) + #expect(share.nativeAmount.currency == .usd) + } + + // 4 + @Test("A group send that does not list me shows nothing") + func groupNotAReceiver() { + let payments = [ + CashPayment(receiverID: third, nativeAmount: 10, quarks: 10_000_000), + CashPayment(receiverID: UUID(), nativeAmount: 10, quarks: 10_000_000), + ] + #expect(amount(cash(payments: payments), context()) == nil) + } + + @Test("A single-receiver send to someone else shows nothing") + func singleReceiverNotMe() { + #expect(amount(cash(receiver: third), context()) == nil) + } + + @Test("A legacy DM cash message with no receiver on record pays me in full") + func legacyNoReceiver() { + #expect(amount(cash(), context()) == 20) + } + + @Test("Text is never a payment") + func textMessage() { + let text = ConversationMessage( + id: MessageID(value: 10), senderID: them, content: .text("hi"), + date: Date(timeIntervalSince1970: 2_000_000), unreadSeq: 1 + ) + #expect(ChatCashBill.billAmount(for: text, in: context()) == nil) + } + + // 5 + @Test("Cash in a chat that is not the open one shows nothing") + func otherChat() { + #expect(amount(cash(receiver: me), context(in: chatB)) == nil) + } + + // 6 + @Test("With no chat open nothing shows") + func noChatOpen() { + #expect(amount(cash(receiver: me), context(open: .some(nil))) == nil) + } + + // 7 + @Test("Catch-up, delta and initial-load deliveries show nothing") + func catchUp() { + #expect(amount(cash(receiver: me), context(source: .catchUp)) == nil) + } + + @Test("A re-delivery at or below the stored watermark shows nothing") + func redelivery() { + #expect(amount(cash(id: 5, receiver: me), context(countedThrough: MessageID(value: 5))) == nil) + #expect(amount(cash(id: 4, receiver: me), context(countedThrough: MessageID(value: 5))) == nil) + #expect(amount(cash(id: 6, receiver: me), context(countedThrough: MessageID(value: 5))) == 20) + } + + @Test("The first message in an empty chat has no watermark and still counts") + func emptyChatFirstMessage() { + #expect(amount(cash(receiver: me), context(countedThrough: nil)) == 20) + } + + // 8, 9 + @Test("Anything over the chat, or the chat inside a sheet, drops the payment") + func somethingOnTop() { + #expect(amount(cash(receiver: me), context(onTop: true)) == nil) + } + + // 10 + @Test("While a chat bill is showing or preparing, further payments are dropped") + func firstOneWins() { + #expect(amount(cash(receiver: me), context(busy: true)) == nil) + // Not queued: once the bill closes, the same message no longer qualifies by being replayed + // through the live path, because the caller only evaluates each message as it lands. + #expect(amount(cash(id: 11, receiver: me), context(busy: false)) == 20) + } + + // 11 + @Test("With the flag off nothing shows") + func flagOff() { + #expect(amount(cash(receiver: me), context(flag: false)) == nil) + } + + // 17 + @Test("A tip, a request payment and a Send Back return each show a bill") + func tipRequestAndReturn() { + let tip = cash(receiver: me, action: .tipped) + let request = cash(receiver: me, requestID: PaymentRequestID(data: Data([1, 2, 3]))) + let sendBack = cash( + receiver: me, + cashReturn: CashReturn(hasOffer: false, returnOf: .init(messageID: MessageID(value: 3))) + ) + for message in [tip, request, sendBack] { + #expect(amount(message, context()) == 20) + } + } +} diff --git a/FlipcashTests/BetaFlagsTests.swift b/FlipcashTests/BetaFlagsTests.swift index 0bf091ccf..70a399e37 100644 --- a/FlipcashTests/BetaFlagsTests.swift +++ b/FlipcashTests/BetaFlagsTests.swift @@ -48,6 +48,22 @@ struct BetaFlagsTests { } } + @Test("Chat Cash Bill is public, starts on, and stays off once the user turns it off") + func chatCashBill_publicDefaultOnAndOffSticks() { + #expect(BetaFlags.Option.chatCashBill.availability == .publicBeta) + #expect(BetaFlags.Option.chatCashBill.isOnByDefault) + #expect(BetaFlags.Option.chatCashBill.localizedTitle == "Chat Cash Bill") + #expect(BetaFlags.Option.chatCashBill.localizedDescription == "Show the bill when someone sends you cash in the chat you have open.") + + let first = BetaFlags() + #expect(first.hasEnabled(.chatCashBill)) + + first.set(.chatCashBill, enabled: false) + + let relaunch = BetaFlags() + #expect(!relaunch.hasEnabled(.chatCashBill)) + } + // MARK: - Launch argument overrides - @Test("An override replaces the whole set, so a default-on flag can run off", needsADefaultOnOption) diff --git a/FlipcashTests/Chat/ChatCashBillControllerTests.swift b/FlipcashTests/Chat/ChatCashBillControllerTests.swift new file mode 100644 index 000000000..c1b128c36 --- /dev/null +++ b/FlipcashTests/Chat/ChatCashBillControllerTests.swift @@ -0,0 +1,256 @@ +// +// ChatCashBillControllerTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@MainActor +private final class FakeKeyboard: ChatComposerKeyboard { + var isUp: Bool + private(set) var lowered = 0 + private(set) var raised = 0 + init(isUp: Bool) { self.isUp = isUp } + func lower() { lowered += 1; isUp = false } + func raise() { raised += 1; isUp = true } +} + +@Suite("ChatCashBillController") +@MainActor +struct ChatCashBillControllerTests { + + private let me = UUID() + private let them = UUID() + private let chat = ConversationID(data: Data(repeating: 0xA1, count: 32)) + private let otherChat = ConversationID(data: Data(repeating: 0xB2, count: 32)) + + /// A controller wired to fakes. `shown` collects the bills it presents; closing one is the test's call. + private final class Probe { + var shown: [Session.BillDescription] = [] + var onTop = false + var foreground = true + var billShowing = false + var open: ConversationID? + var verified: VerifiedState? = .fresh() + var gate: CheckedContinuation? + } + + private func make(keyboardUp: Bool, probe: Probe) -> (ChatCashBillController, FakeKeyboard) { + let controller = ChatCashBillController(selfUserID: me) + let keyboard = FakeKeyboard(isUp: keyboardUp) + controller.keyboard = keyboard + controller.isEnabled = { true } + controller.openConversationID = { probe.open } + controller.somethingOnTop = { probe.onTop } + controller.isForeground = { probe.foreground } + controller.isBillShowing = { probe.billShowing } + controller.verifiedState = { _ in + await withCheckedContinuation { probe.gate = $0 } + return probe.verified + } + controller.present = { description in + probe.shown.append(description) + probe.billShowing = true + } + return (controller, keyboard) + } + + private func cash(id: UInt64 = 10) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), senderID: them, + content: .cash(ExchangedFiat(nativeAmount: .usd(20), rate: .oneToOne)), + cashAction: .sent, cashReceiverID: me, + date: Date(timeIntervalSince1970: 2_000_000), unreadSeq: 1 + ) + } + + /// Lets the controller's task reach its verified-state await, then releases it. + private func settle(_ probe: Probe) async { + for _ in 0..<50 where probe.gate == nil { await Task.yield() } + probe.gate?.resume() + probe.gate = nil + for _ in 0..<20 { await Task.yield() } + } + + private func closeBill(_ probe: Probe, _ controller: ChatCashBillController) { + probe.billShowing = false + controller.billDidClose() + } + + // 1, 16 (toast half) + @Test("A live payment presents a received bill of my amount with no deposit toast") + func presentsReceivedBillWithoutToast() async throws { + let probe = Probe(); probe.open = chat + let (controller, _) = make(keyboardUp: false, probe: probe) + + controller.handleLive([cash()], in: chat, countedThrough: MessageID(value: 1)) + await settle(probe) + + let bill = try #require(probe.shown.first) + #expect(bill.received) + #expect(!bill.announcesDeposit) + #expect(bill.exchangedFiat.nativeAmount.value == 20) + #expect(bill.verifiedState != nil) + } + + // 12 + @Test("Keyboard up: lowered for the bill, raised when it closes") + func keyboardRestoredOnClose() async { + let probe = Probe(); probe.open = chat + let (controller, keyboard) = make(keyboardUp: true, probe: probe) + + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + #expect(keyboard.lowered == 1) + #expect(!keyboard.isUp) + + closeBill(probe, controller) + #expect(keyboard.raised == 1) + #expect(keyboard.isUp) + } + + // 13: swipe, scrim and back all end in the same close callback. + @Test("Any close route restores the keyboard once, and a second close does not raise it again") + func closeRestoresOnce() async { + let probe = Probe(); probe.open = chat + let (controller, keyboard) = make(keyboardUp: true, probe: probe) + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + + closeBill(probe, controller) + controller.billDidClose() + + #expect(keyboard.raised == 1) + } + + // 14 + @Test("Keyboard down stays down") + func keyboardDownStaysDown() async { + let probe = Probe(); probe.open = chat + let (controller, keyboard) = make(keyboardUp: false, probe: probe) + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + closeBill(probe, controller) + + #expect(keyboard.lowered == 0) + #expect(keyboard.raised == 0) + } + + // 15 + @Test("A bill closed by backgrounding does not raise the keyboard on return") + func backgroundedDoesNotRestore() async { + let probe = Probe(); probe.open = chat + let (controller, keyboard) = make(keyboardUp: true, probe: probe) + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + + probe.foreground = false + closeBill(probe, controller) + probe.foreground = true + controller.billDidClose() + + #expect(keyboard.raised == 0) + } + + // 10 + @Test("A second payment while the first is preparing or showing is dropped and not queued") + func secondPaymentDropped() async { + let probe = Probe(); probe.open = chat + let (controller, _) = make(keyboardUp: false, probe: probe) + + controller.handleLive([cash(id: 10)], in: chat, countedThrough: nil) + // Still waiting on verified state: the bill is not up yet. + controller.handleLive([cash(id: 11)], in: chat, countedThrough: MessageID(value: 10)) + await settle(probe) + // Showing. + controller.handleLive([cash(id: 12)], in: chat, countedThrough: MessageID(value: 11)) + await settle(probe) + #expect(probe.shown.count == 1) + + closeBill(probe, controller) + await settle(probe) + #expect(probe.shown.count == 1) + } + + // 8, 9 + @Test("Something on top drops the payment before any bill is prepared") + func onTopDrops() async { + let probe = Probe(); probe.open = chat; probe.onTop = true + let (controller, keyboard) = make(keyboardUp: true, probe: probe) + + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + + #expect(probe.shown.isEmpty) + #expect(keyboard.lowered == 0) + } + + @Test("A sheet that opens while verified state is awaited drops the bill and leaves the keyboard alone") + func sheetOpensDuringPrepare() async { + let probe = Probe(); probe.open = chat + let (controller, keyboard) = make(keyboardUp: true, probe: probe) + + controller.handleLive([cash()], in: chat, countedThrough: nil) + probe.onTop = true + await settle(probe) + + #expect(probe.shown.isEmpty) + #expect(keyboard.lowered == 0) + } + + @Test("Without verified state no bill shows, since its give operation would fail") + func noVerifiedStateNoBill() async { + let probe = Probe(); probe.open = chat; probe.verified = nil + let (controller, _) = make(keyboardUp: false, probe: probe) + + controller.handleLive([cash()], in: chat, countedThrough: nil) + await settle(probe) + + #expect(probe.shown.isEmpty) + } + + // 5, 6, 11 + @Test("Another chat, no chat open, or the flag off: nothing is prepared") + func notQualifying() async { + let probe = Probe(); probe.open = otherChat + let (controller, _) = make(keyboardUp: false, probe: probe) + controller.handleLive([cash()], in: chat, countedThrough: nil) + #expect(probe.gate == nil) + + probe.open = nil + controller.handleLive([cash()], in: chat, countedThrough: nil) + #expect(probe.gate == nil) + + probe.open = chat + controller.isEnabled = { false } + controller.handleLive([cash()], in: chat, countedThrough: nil) + #expect(probe.gate == nil) + #expect(probe.shown.isEmpty) + } + + // 19 + @Test("The message that raises the bill is the one the chat skips its haptic for") + func hapticSkippedForBilledMessage() async { + let probe = Probe(); probe.open = chat + let (controller, _) = make(keyboardUp: false, probe: probe) + + controller.handleLive([cash(id: 10)], in: chat, countedThrough: nil) + + #expect(controller.isBilling(MessageID(value: 10))) + #expect(!controller.isBilling(MessageID(value: 11))) + await settle(probe) + } + + @Test("A dropped payment keeps its haptic") + func droppedPaymentKeepsHaptic() async { + let probe = Probe(); probe.open = chat; probe.onTop = true + let (controller, _) = make(keyboardUp: false, probe: probe) + + controller.handleLive([cash(id: 10)], in: chat, countedThrough: nil) + + #expect(!controller.isBilling(MessageID(value: 10))) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 4ee1719a0..699d4561b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -797,6 +797,11 @@ public final class ChatScreenViewController: UIViewController { keyboardFloor?.overlap ?? 0 } + /// Whether the composer's field is first responder, that is, the keyboard is up for it. + public var composerHoldsKeyboard: Bool { + bar.firstTextInputResponder?.isFirstResponder ?? false + } + /// Whether the composer's field holds the keyboard and can take a replacement input view. public var canReplaceComposerInputView: Bool { guard let responder = bar.firstTextInputResponder, responder.isFirstResponder else { return false }