Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ extension BetaFlags {

case vibrateOnScan
case enableCoinbase
case chatCashBill

var id: String {
localizedTitle
Expand All @@ -191,6 +192,8 @@ extension BetaFlags {
return "Vibrate on scan"
case .enableCoinbase:
return "Enable Coinbase"
case .chatCashBill:
return "Chat Cash Bill"
}
}

Expand All @@ -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."
}
}

Expand All @@ -208,6 +213,7 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return .developer
case .enableCoinbase: return .developer
case .chatCashBill: return .publicBeta
}
}

Expand All @@ -217,6 +223,7 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return false
case .enableCoinbase: return false
case .chatCashBill: return true
}
}
}
Expand Down
175 changes: 175 additions & 0 deletions Flipcash/Core/Controllers/ChatCashBillController.swift
Original file line number Diff line number Diff line change
@@ -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 <udid> 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
}
4 changes: 4 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 14 additions & 3 deletions Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading