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 }